use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Once;
use crate::error::AppError;
const TEST_HOME_ENV: &str = "CC_SWITCH_TEST_HOME";
#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")]
#[cfg_attr(
any(
target_os = "linux",
target_os = "android",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
),
link_section = ".init_array"
)]
#[cfg_attr(windows, link_section = ".CRT$XCU")]
#[used]
static AUTO_TEST_HOME_INIT: extern "C" fn() = auto_test_home_ctor;
extern "C" fn auto_test_home_ctor() {
initialize_auto_test_home_env();
}
fn ensure_auto_test_home_env() {
static INIT: Once = Once::new();
INIT.call_once(initialize_auto_test_home_env);
}
fn initialize_auto_test_home_env() {
if !is_test_executable() {
return;
}
let home = env::var_os(TEST_HOME_ENV)
.map(PathBuf::from)
.unwrap_or_else(default_auto_test_home);
let _ = fs::create_dir_all(&home);
env::set_var("HOME", &home);
#[cfg(windows)]
env::set_var("USERPROFILE", &home);
env::set_var(TEST_HOME_ENV, &home);
env::remove_var("CC_SWITCH_TUI_CONFIG_DIR");
env::remove_var("CC_SWITCH_CONFIG_DIR");
env::remove_var("CLAUDE_CONFIG_DIR");
env::remove_var("CODEX_HOME");
env::remove_var("HERMES_HOME");
}
fn is_test_executable() -> bool {
env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))
.and_then(|path| path.file_name().map(|name| name.to_owned()))
.is_some_and(|name| name == "deps")
}
fn default_auto_test_home() -> PathBuf {
env::temp_dir().join(format!("cc-switch-test-home-{}", std::process::id()))
}
pub(crate) fn auto_test_home() -> Option<PathBuf> {
ensure_auto_test_home_env();
if !is_test_executable() {
return None;
}
env::var_os(TEST_HOME_ENV).map(PathBuf::from)
}
pub(crate) fn home_dir() -> Option<PathBuf> {
#[cfg(test)]
if let Some(home) = crate::test_support::test_home_override() {
return Some(home);
}
if let Some(home) = auto_test_home() {
return Some(home);
}
dirs::home_dir()
}
fn migrate_legacy_config_dir_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static MIGRATED: AtomicBool = AtomicBool::new(false);
if !MIGRATED.swap(true, Ordering::Relaxed) {
migrate_legacy_config_dir_if_needed();
}
}
pub(crate) fn expand_tilde(path: PathBuf) -> PathBuf {
let lossy = path.to_string_lossy();
if lossy == "~" {
return home_dir().unwrap_or(path);
}
if let Some(rest) = lossy
.strip_prefix("~/")
.or_else(|| lossy.strip_prefix("~\\"))
{
if let Some(home) = home_dir() {
return home.join(rest);
}
}
path
}
pub fn get_claude_config_dir() -> PathBuf {
if let Some(dir) = claude_config_dir_from_env() {
return dir;
}
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
home_dir().expect("无法获取用户主目录").join(".claude")
}
pub fn get_default_claude_mcp_path() -> PathBuf {
home_dir().expect("无法获取用户主目录").join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
let file_name = dir
.file_name()
.map(|name| name.to_string_lossy().to_string())?
.trim()
.to_string();
if file_name.is_empty() {
return None;
}
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
Some(parent.join(format!("{file_name}.json")))
}
fn effective_app_config_dir_without_migration(home: &Path) -> Option<PathBuf> {
if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
let custom = PathBuf::from(custom);
if !custom.to_string_lossy().trim().is_empty() {
return Some(expand_tilde(custom));
}
}
if env::var_os("CC_SWITCH_CONFIG_DIR").is_some() {
return None;
}
Some(home.join(".cc-switch-tui"))
}
fn claude_config_dir_from_env() -> Option<PathBuf> {
let dir = std::env::var_os("CLAUDE_CONFIG_DIR")?;
let dir = PathBuf::from(dir);
if dir.as_os_str().is_empty() || dir.to_string_lossy().trim().is_empty() {
return None;
}
Some(expand_tilde(dir))
}
pub fn get_claude_mcp_path() -> PathBuf {
if let Some(env_dir) = claude_config_dir_from_env() {
if let Some(path) = derive_mcp_path_from_override(&env_dir) {
return path;
}
}
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
return path;
}
}
get_default_claude_mcp_path()
}
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
}
pub fn get_app_config_dir() -> PathBuf {
if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
let custom = PathBuf::from(custom);
if !custom.to_string_lossy().trim().is_empty() {
migrate_legacy_config_dir_once();
return expand_tilde(custom);
}
}
if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") {
let custom = PathBuf::from(custom);
if custom.to_string_lossy().trim().is_empty() {
return home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch-tui");
}
eprintln!("deprecated: CC_SWITCH_CONFIG_DIR is set; use CC_SWITCH_TUI_CONFIG_DIR instead");
return expand_tilde(custom);
}
let path = home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch-tui");
migrate_legacy_config_dir_once();
path
}
pub fn get_app_config_path() -> PathBuf {
get_app_config_dir().join("config.json")
}
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
_ => c,
})
.collect::<String>()
.to_lowercase()
}
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"))
}
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))
}
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 json =
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
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())
}
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)]
{
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::*;
use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
use std::ffi::OsString;
struct ConfigDirEnvGuard {
key: String,
original: Option<OsString>,
}
impl ConfigDirEnvGuard {
fn new(key: &str, value: Option<&str>) -> Self {
let original = env::var_os(key);
match value {
Some(v) => unsafe { env::set_var(key, v) },
None => unsafe { env::remove_var(key) },
}
Self {
key: key.to_string(),
original,
}
}
}
impl Drop for ConfigDirEnvGuard {
fn drop(&mut self) {
match self.original.as_ref() {
Some(value) => unsafe { env::set_var(&self.key, value) },
None => unsafe { env::remove_var(&self.key) },
}
}
}
struct SettingsGuard {
original: crate::settings::AppSettings,
}
impl SettingsGuard {
fn with_claude_config_dir(dir: Option<&str>) -> Self {
let original = crate::settings::get_settings();
let mut settings = original.clone();
settings.claude_config_dir = dir.map(str::to_string);
crate::settings::update_settings(settings).unwrap();
Self { original }
}
}
impl Drop for SettingsGuard {
fn drop(&mut self) {
let _ = crate::settings::update_settings(self.original.clone());
}
}
#[test]
fn derive_mcp_path_from_override_preserves_folder_name() {
let override_dir = PathBuf::from("/tmp/profile/.claude");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for nested dir");
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
}
#[test]
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
let override_dir = PathBuf::from("/data/claude-config");
let derived = derive_mcp_path_from_override(&override_dir)
.expect("should derive path for standard dir");
assert_eq!(derived, PathBuf::from("/data/claude-config.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)
.expect("should derive path for single segment");
assert_eq!(derived, PathBuf::from("claude.json"));
}
#[test]
fn derive_mcp_path_from_root_like_dir_returns_none() {
let override_dir = PathBuf::from("/");
assert!(derive_mcp_path_from_override(&override_dir).is_none());
}
#[test]
fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
);
set_test_home_override(None);
}
#[test]
fn get_app_config_dir_uses_env_override_when_set() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new(
"CC_SWITCH_CONFIG_DIR",
Some("/tmp/cc-switch-config-override"),
);
set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-config-override")
);
set_test_home_override(None);
}
#[test]
fn get_app_config_dir_ignores_blank_env_override() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(" "));
set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
);
set_test_home_override(None);
}
#[test]
fn get_app_config_dir_prefers_new_env_var() {
let _guard = lock_test_home_and_settings();
let _new =
ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-tui-new")
);
set_test_home_override(None);
}
#[test]
fn get_app_config_dir_new_env_var_alone_works() {
let _guard = lock_test_home_and_settings();
let _new =
ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-tui-alone")
);
set_test_home_override(None);
}
#[test]
fn get_app_config_dir_expands_tilde_in_new_env_var() {
let _guard = lock_test_home_and_settings();
let _new =
ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
assert_eq!(
get_app_config_dir(),
PathBuf::from("/tmp/cc-switch-home-tilde")
.join(".config")
.join("cc-switch-tui")
);
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_expands_tilde_in_env_var() {
let _guard = lock_test_home_and_settings();
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
assert_eq!(
get_claude_config_dir(),
PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
);
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_respects_env_var() {
let _guard = lock_test_home_and_settings();
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
set_test_home_override(Some(Path::new("/tmp/claude-home")));
assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
set_test_home_override(None);
}
#[test]
fn get_claude_mcp_path_respects_env_var() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
set_test_home_override(Some(Path::new("/tmp/claude-home")));
assert_eq!(
get_claude_mcp_path(),
PathBuf::from("/tmp").join("claude-custom.json")
);
set_test_home_override(None);
}
#[test]
fn get_claude_mcp_path_expands_tilde_in_env_var() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(None);
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
assert_eq!(
get_claude_mcp_path(),
PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom.json")
);
set_test_home_override(None);
}
#[test]
fn get_claude_mcp_path_blank_env_falls_back_to_settings() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
set_test_home_override(Some(Path::new("/tmp/claude-home")));
assert_eq!(
get_claude_mcp_path(),
PathBuf::from("/tmp").join("settings-override.json")
);
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_ignores_blank_env_var() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(None);
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
assert_eq!(
get_claude_config_dir(),
PathBuf::from("/tmp/claude-home-blank").join(".claude")
);
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(None);
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
set_test_home_override(Some(Path::new("/tmp/default-home")));
assert_eq!(
get_claude_config_dir(),
PathBuf::from("/tmp/default-home").join(".claude")
);
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_env_overrides_settings() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
set_test_home_override(Some(Path::new("/tmp/home")));
assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
set_test_home_override(None);
}
#[test]
fn get_claude_config_dir_blank_env_falls_back_to_settings() {
let _guard = lock_test_home_and_settings();
let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
set_test_home_override(Some(Path::new("/tmp/home")));
assert_eq!(
get_claude_config_dir(),
PathBuf::from("/tmp/settings-override")
);
set_test_home_override(None);
}
#[test]
fn migration_copies_config_json_and_db() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
let marker = new_dir.join(".migrated-from-cc-switch");
fs::create_dir_all(old_dir.join("skills")).unwrap();
fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
migrate_legacy_config_dir_if_needed();
assert!(
new_dir.join("config.json").exists(),
"config.json should be copied"
);
assert!(
new_dir.join("cc-switch.db").exists(),
"cc-switch.db should be copied"
);
assert!(
new_dir.join("skills").join("my-skill.md").exists(),
"skills/ should be recursively copied"
);
assert!(marker.exists(), "migration marker should be written");
set_test_home_override(None);
}
#[test]
fn migration_skips_when_target_has_marker() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
let marker = new_dir.join(".migrated-from-cc-switch");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "v1").unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(&marker, "already migrated").unwrap();
migrate_legacy_config_dir_if_needed();
assert!(
!new_dir.join("config.json").exists(),
"should not copy when marker exists"
);
set_test_home_override(None);
}
#[test]
fn migration_skips_when_legacy_env_override_set() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old =
ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "v1").unwrap();
migrate_legacy_config_dir_if_needed();
assert!(
!new_dir.exists(),
"should not create target dir when env override set"
);
set_test_home_override(None);
}
#[test]
fn migration_uses_new_env_override_as_target() {
let _guard = lock_test_home_and_settings();
let _tui =
ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".config").join("cc-switch-tui");
let marker = new_dir.join(".migrated-from-cc-switch");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "v1").unwrap();
assert_eq!(
legacy_config_migration_paths(),
Some((old_dir.clone(), new_dir.clone()))
);
migrate_legacy_config_dir_if_needed();
assert!(new_dir.join("config.json").exists());
assert!(marker.exists());
set_test_home_override(None);
}
#[test]
fn migration_skips_when_target_already_has_contents() {
let _guard = lock_test_home_and_settings();
let _tui =
ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".config").join("cc-switch-tui");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "legacy").unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(new_dir.join("config.json"), "current").unwrap();
assert_eq!(legacy_config_migration_paths(), None);
migrate_legacy_config_dir_if_needed();
assert_eq!(
fs::read_to_string(new_dir.join("config.json")).unwrap(),
"current"
);
assert!(!new_dir.join(".migrated-from-cc-switch").exists());
set_test_home_override(None);
}
#[test]
fn migration_copies_settings_json_when_target_only_has_db() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
assert_eq!(
legacy_config_migration_paths(),
Some((old_dir.clone(), new_dir.clone()))
);
migrate_legacy_config_dir_if_needed();
assert_eq!(
fs::read_to_string(new_dir.join("settings.json")).unwrap(),
"legacy-settings"
);
assert_eq!(
fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
"current-db",
"existing target database must not be overwritten"
);
assert!(new_dir.join(".migrated-from-cc-switch").exists());
set_test_home_override(None);
}
#[test]
fn migration_repairs_missing_settings_json_after_success_marker() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
let marker = new_dir.join(".migrated-from-cc-switch");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
fs::write(&marker, "Migrated from old path").unwrap();
assert_eq!(
legacy_config_migration_paths(),
None,
"already-migrated directories should not prompt again"
);
migrate_legacy_config_dir_if_needed();
assert_eq!(
fs::read_to_string(new_dir.join("settings.json")).unwrap(),
"legacy-settings"
);
assert_eq!(
fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
"current-db",
"repair must not overwrite the existing target database"
);
set_test_home_override(None);
}
#[test]
fn migration_replaces_generated_target_settings_with_legacy_preferences() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
fs::create_dir_all(&old_dir).unwrap();
fs::write(
old_dir.join("settings.json"),
r#"{
"language": "zh",
"visibleApps": {
"claude": true,
"codex": true,
"gemini": true,
"opencode": true,
"openclaw": true,
"hermes": true
},
"currentProviderClaude": "legacy-current"
}"#,
)
.unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
fs::write(
new_dir.join("settings.json"),
r#"{
"language": "en",
"visibleApps": {
"claude": true,
"codex": true,
"gemini": false,
"opencode": true,
"openclaw": false,
"hermes": false
}
}"#,
)
.unwrap();
assert_eq!(
legacy_config_migration_paths(),
Some((old_dir.clone(), new_dir.clone()))
);
migrate_legacy_config_dir_if_needed();
let migrated_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&migrated_settings).unwrap();
assert_eq!(value["language"], "zh");
assert_eq!(value["visibleApps"]["gemini"], true);
assert_eq!(value["visibleApps"]["openclaw"], true);
assert_eq!(value["visibleApps"]["hermes"], true);
assert_eq!(value["currentProviderClaude"], "legacy-current");
assert_eq!(
fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
"current-db",
"existing target database must not be overwritten"
);
assert!(new_dir.join(".migrated-from-cc-switch").exists());
set_test_home_override(None);
}
#[test]
fn migration_does_not_replace_configured_target_settings() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
fs::create_dir_all(&old_dir).unwrap();
fs::write(
old_dir.join("settings.json"),
r#"{
"language": "zh",
"visibleApps": {
"claude": true,
"codex": true,
"gemini": true,
"opencode": true,
"openclaw": true,
"hermes": true
}
}"#,
)
.unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
fs::write(
new_dir.join("settings.json"),
r#"{
"language": "zh",
"webdavSync": {
"enabled": true,
"baseUrl": "https://dav.example.com",
"username": "user",
"password": "pass"
},
"visibleApps": {
"claude": true,
"codex": true,
"gemini": false,
"opencode": true,
"openclaw": false,
"hermes": false
}
}"#,
)
.unwrap();
assert_eq!(
legacy_config_migration_paths(),
None,
"configured target settings should block implicit repair"
);
migrate_legacy_config_dir_if_needed();
let target_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&target_settings).unwrap();
assert_eq!(value["webdavSync"]["enabled"], true);
assert_eq!(value["visibleApps"]["gemini"], false);
assert!(
!new_dir.join(".migrated-from-cc-switch").exists(),
"blocked repair must not write the migration marker"
);
set_test_home_override(None);
}
#[test]
fn migration_is_idempotent() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let new_dir = home.join(".cc-switch-tui");
let marker = new_dir.join(".migrated-from-cc-switch");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "v1").unwrap();
migrate_legacy_config_dir_if_needed();
assert!(new_dir.join("config.json").exists());
let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
migrate_legacy_config_dir_if_needed();
let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
assert_eq!(
mtime_after_first, mtime_after_second,
"second migration should not overwrite marker"
);
set_test_home_override(None);
}
#[test]
fn migration_preserves_old_directory() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("config.json"), "v1").unwrap();
migrate_legacy_config_dir_if_needed();
assert!(old_dir.exists(), "old directory must be preserved");
assert!(
old_dir.join("config.json").exists(),
"old files must be preserved"
);
set_test_home_override(None);
}
#[test]
fn migration_copies_only_3_most_recent_backups() {
let _guard = lock_test_home_and_settings();
let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
let temp = tempfile::tempdir().expect("create temp dir");
let home = temp.path();
set_test_home_override(Some(home));
let old_dir = home.join(".cc-switch");
let backup_dir = old_dir.join("backups");
fs::create_dir_all(&backup_dir).unwrap();
for i in 1..=5 {
let path = backup_dir.join(format!("backup-{}.json", i));
fs::write(&path, format!("backup {}", i)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
}
migrate_legacy_config_dir_if_needed();
let new_backup_dir = home.join(".cc-switch-tui").join("backups");
let copied: Vec<_> = fs::read_dir(&new_backup_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert_eq!(
copied.len(),
3,
"only 3 most recent backups should be copied"
);
assert!(
copied.contains(&"backup-3.json".to_string()),
"third most recent should be copied"
);
assert!(
copied.contains(&"backup-4.json".to_string()),
"second most recent should be copied"
);
assert!(
copied.contains(&"backup-5.json".to_string()),
"most recent should be copied"
);
set_test_home_override(None);
}
}
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(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else if !dst_path.exists() {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
let mut entries: Vec<_> = fs::read_dir(src)?
.filter_map(|e| e.ok())
.filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
.collect();
entries.sort_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH)
});
entries.reverse();
entries.truncate(limit);
fs::create_dir_all(dst)?;
for entry in entries {
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if entry.file_type().map_or(false, |t| t.is_dir()) {
copy_dir_recursive(&src_path, &dst_path)?;
} else if !dst_path.exists() {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn target_allows_legacy_migration(old_dir: &Path, new_dir: &Path) -> bool {
if !new_dir.exists() {
return true;
}
if !new_dir.is_dir() {
return false;
}
let entries = match fs::read_dir(new_dir) {
Ok(entries) => entries,
Err(_) => return false,
};
for entry in entries {
let Ok(entry) = entry else {
return false;
};
let file_name = entry.file_name();
if file_name == "cc-switch.db" {
continue;
}
if file_name == "settings.json"
&& legacy_settings_should_replace_target(
&old_dir.join("settings.json"),
&new_dir.join("settings.json"),
)
{
continue;
}
return false;
}
true
}
fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
["settings.json", "config.json"]
.iter()
.any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
|| legacy_settings_should_replace_target(
&old_dir.join("settings.json"),
&new_dir.join("settings.json"),
)
}
fn migration_marker_allows_repair(marker: &Path) -> bool {
match fs::read_to_string(marker) {
Ok(content) => content.starts_with("Migrated from "),
Err(_) => false,
}
}
fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
let home = home_dir()?;
let old_dir = home.join(".cc-switch");
let new_dir = effective_app_config_dir_without_migration(&home)?;
let marker = new_dir.join(".migrated-from-cc-switch");
if old_dir == new_dir {
return None;
}
if !old_dir.exists() || !old_dir.is_dir() {
return None;
}
if marker.exists() {
if !allow_repair
|| !migration_marker_allows_repair(&marker)
|| !needs_legacy_json_repair(&old_dir, &new_dir)
{
return None;
}
}
let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
if !has_contents {
return None;
}
if !marker.exists() && !target_allows_legacy_migration(&old_dir, &new_dir) {
return None;
}
Some((old_dir, new_dir, marker))
}
pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
}
pub fn check_legacy_config_dir_migration_needed() -> bool {
legacy_config_migration_paths().is_some()
}
pub fn skip_legacy_config_dir_migration() {
let (_, new_dir, marker) = match migration_guard(false) {
Some(v) => v,
None => return,
};
if let Err(e) = std::fs::create_dir_all(&new_dir)
.and_then(|_| std::fs::write(&marker, "User declined migration"))
{
eprintln!(
"cc-switch: failed to write skip-migration marker at {}: {e}",
marker.display()
);
}
}
pub fn migrate_legacy_config_dir_if_needed() {
let (old_dir, new_dir, marker) = match migration_guard(true) {
Some(v) => v,
None => return,
};
if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
eprintln!(
"cc-switch: legacy config migration failed: {e} (old data preserved at {})",
old_dir.display()
);
}
}
fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
fs::create_dir_all(new_dir)?;
for entry in fs::read_dir(old_dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let src_path = entry.path();
let file_name = entry.file_name();
let dst_path = new_dir.join(&file_name);
if file_name == "backups" && file_type.is_dir() {
copy_recent_backups(&src_path, &dst_path, 3)?;
} else if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else if file_name == "settings.json"
&& legacy_settings_should_replace_target(&src_path, &dst_path)
{
fs::copy(&src_path, &dst_path)?;
} else if !dst_path.exists() {
fs::copy(&src_path, &dst_path)?;
}
}
fs::write(
marker,
format!(
"Migrated from {} on {}",
old_dir.display(),
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
),
)?;
eprintln!(
"cc-switch: config migrated from {} to {} (old directory preserved)",
old_dir.display(),
new_dir.display()
);
Ok(())
}
fn legacy_settings_should_replace_target(legacy_path: &Path, target_path: &Path) -> bool {
if !legacy_path.is_file() {
return false;
}
if !target_path.exists() {
return true;
}
let Ok(legacy) = read_json_value(legacy_path) else {
return false;
};
let Ok(target) = read_json_value(target_path) else {
return false;
};
legacy_settings_has_migration_preference(&legacy)
&& target_settings_looks_generated(&target)
&& legacy_settings_are_more_specific(&legacy, &target)
}
fn read_json_value(path: &Path) -> Result<serde_json::Value, serde_json::Error> {
let raw = fs::read_to_string(path).map_err(serde_json::Error::io)?;
serde_json::from_str(&raw)
}
fn legacy_settings_has_migration_preference(value: &serde_json::Value) -> bool {
language_code(value).is_some_and(|lang| lang.eq_ignore_ascii_case("zh"))
|| visible_apps_enabled_count(value) > default_visible_apps_enabled_count()
|| value
.get("currentProviderClaude")
.or_else(|| value.get("currentProviderCodex"))
.or_else(|| value.get("currentProviderGemini"))
.or_else(|| value.get("currentProviderOpencode"))
.or_else(|| value.get("currentProviderOpenclaw"))
.or_else(|| value.get("currentProviderHermes"))
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty())
|| value
.pointer("/webdavSync/enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn target_settings_looks_generated(value: &serde_json::Value) -> bool {
let language_is_default = language_code(value)
.map(|lang| lang.eq_ignore_ascii_case("en"))
.unwrap_or(true);
language_is_default
&& current_provider_fields_empty(value)
&& !value
.pointer("/webdavSync/enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false)
&& value
.get("skillSyncMethod")
.and_then(|value| value.as_str())
.map(|method| method == "auto")
.unwrap_or(true)
}
fn legacy_settings_are_more_specific(
legacy: &serde_json::Value,
target: &serde_json::Value,
) -> bool {
let language_is_more_specific = match (language_code(legacy), language_code(target)) {
(Some(legacy), Some(target)) => !legacy.eq_ignore_ascii_case(target),
(Some(_), None) => true,
_ => false,
};
language_is_more_specific
|| visible_apps_enabled_count(legacy) > visible_apps_enabled_count(target)
|| !current_provider_fields_empty(legacy)
|| legacy
.pointer("/webdavSync/enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn current_provider_fields_empty(value: &serde_json::Value) -> bool {
[
"currentProviderClaude",
"currentProviderCodex",
"currentProviderGemini",
"currentProviderOpencode",
"currentProviderOpenclaw",
"currentProviderHermes",
]
.iter()
.all(|key| {
value
.get(*key)
.and_then(|value| value.as_str())
.map(|value| value.trim().is_empty())
.unwrap_or(true)
})
}
fn language_code(value: &serde_json::Value) -> Option<&str> {
value.get("language").and_then(|value| value.as_str())
}
fn visible_apps_enabled_count(value: &serde_json::Value) -> usize {
value
.get("visibleApps")
.and_then(|value| value.as_object())
.map(|apps| {
apps.values()
.filter(|value| value.as_bool().unwrap_or(false))
.count()
})
.unwrap_or(default_visible_apps_enabled_count())
}
fn default_visible_apps_enabled_count() -> usize {
crate::settings::default_visible_apps()
.ordered_enabled()
.len()
}