Skip to main content

cc_switch_lib/
config.rs

1use serde::{Deserialize, Serialize};
2use std::env;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Once;
7
8use crate::error::AppError;
9
10const TEST_HOME_ENV: &str = "CC_SWITCH_TEST_HOME";
11
12#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")]
13#[cfg_attr(
14    any(
15        target_os = "linux",
16        target_os = "android",
17        target_os = "freebsd",
18        target_os = "openbsd",
19        target_os = "netbsd"
20    ),
21    link_section = ".init_array"
22)]
23#[cfg_attr(windows, link_section = ".CRT$XCU")]
24#[used]
25static AUTO_TEST_HOME_INIT: extern "C" fn() = auto_test_home_ctor;
26
27extern "C" fn auto_test_home_ctor() {
28    initialize_auto_test_home_env();
29}
30
31fn ensure_auto_test_home_env() {
32    static INIT: Once = Once::new();
33    INIT.call_once(initialize_auto_test_home_env);
34}
35
36fn initialize_auto_test_home_env() {
37    if !is_test_executable() {
38        return;
39    }
40
41    let home = env::var_os(TEST_HOME_ENV)
42        .map(PathBuf::from)
43        .unwrap_or_else(default_auto_test_home);
44    let _ = fs::create_dir_all(&home);
45    env::set_var("HOME", &home);
46    #[cfg(windows)]
47    env::set_var("USERPROFILE", &home);
48    env::set_var(TEST_HOME_ENV, &home);
49    env::remove_var("CC_SWITCH_TUI_CONFIG_DIR");
50    env::remove_var("CC_SWITCH_CONFIG_DIR");
51    env::remove_var("CLAUDE_CONFIG_DIR");
52    env::remove_var("CODEX_HOME");
53    env::remove_var("HERMES_HOME");
54}
55
56fn is_test_executable() -> bool {
57    env::current_exe()
58        .ok()
59        .and_then(|path| path.parent().map(Path::to_path_buf))
60        .and_then(|path| path.file_name().map(|name| name.to_owned()))
61        .is_some_and(|name| name == "deps")
62}
63
64fn default_auto_test_home() -> PathBuf {
65    env::temp_dir().join(format!("cc-switch-test-home-{}", std::process::id()))
66}
67
68pub(crate) fn auto_test_home() -> Option<PathBuf> {
69    ensure_auto_test_home_env();
70    if !is_test_executable() {
71        return None;
72    }
73    env::var_os(TEST_HOME_ENV).map(PathBuf::from)
74}
75
76pub(crate) fn home_dir() -> Option<PathBuf> {
77    #[cfg(test)]
78    if let Some(home) = crate::test_support::test_home_override() {
79        return Some(home);
80    }
81
82    if let Some(home) = auto_test_home() {
83        return Some(home);
84    }
85
86    dirs::home_dir()
87}
88
89fn migrate_legacy_config_dir_once() {
90    // AtomicBool guard: 进程内只跑一次,避免测试并发和重复 stat 调用
91    use std::sync::atomic::{AtomicBool, Ordering};
92    static MIGRATED: AtomicBool = AtomicBool::new(false);
93    if !MIGRATED.swap(true, Ordering::Relaxed) {
94        migrate_legacy_config_dir_if_needed();
95    }
96}
97
98/// If `path` starts with `~` / `~/`, replace the tilde with the home directory.
99/// Otherwise return the path unchanged.
100pub(crate) fn expand_tilde(path: PathBuf) -> PathBuf {
101    let lossy = path.to_string_lossy();
102    if lossy == "~" {
103        return home_dir().unwrap_or(path);
104    }
105
106    if let Some(rest) = lossy
107        .strip_prefix("~/")
108        .or_else(|| lossy.strip_prefix("~\\"))
109    {
110        if let Some(home) = home_dir() {
111            return home.join(rest);
112        }
113    }
114
115    path
116}
117
118/// 获取 Claude Code 配置目录路径
119///
120/// Priority: `CLAUDE_CONFIG_DIR` env var > cc-switch settings override > `$HOME/.claude`
121pub fn get_claude_config_dir() -> PathBuf {
122    if let Some(dir) = claude_config_dir_from_env() {
123        return dir;
124    }
125    if let Some(custom) = crate::settings::get_claude_override_dir() {
126        return custom;
127    }
128
129    home_dir().expect("无法获取用户主目录").join(".claude")
130}
131
132/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
133pub fn get_default_claude_mcp_path() -> PathBuf {
134    home_dir().expect("无法获取用户主目录").join(".claude.json")
135}
136
137fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
138    let file_name = dir
139        .file_name()
140        .map(|name| name.to_string_lossy().to_string())?
141        .trim()
142        .to_string();
143    if file_name.is_empty() {
144        return None;
145    }
146    let parent = dir.parent().unwrap_or_else(|| Path::new(""));
147    Some(parent.join(format!("{file_name}.json")))
148}
149
150fn effective_app_config_dir_without_migration(home: &Path) -> Option<PathBuf> {
151    if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
152        let custom = PathBuf::from(custom);
153        if !custom.to_string_lossy().trim().is_empty() {
154            return Some(expand_tilde(custom));
155        }
156    }
157
158    if env::var_os("CC_SWITCH_CONFIG_DIR").is_some() {
159        return None;
160    }
161
162    Some(home.join(".cc-switch-tui"))
163}
164
165fn claude_config_dir_from_env() -> Option<PathBuf> {
166    let dir = std::env::var_os("CLAUDE_CONFIG_DIR")?;
167    let dir = PathBuf::from(dir);
168    if dir.as_os_str().is_empty() || dir.to_string_lossy().trim().is_empty() {
169        return None;
170    }
171    Some(expand_tilde(dir))
172}
173
174/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
175pub fn get_claude_mcp_path() -> PathBuf {
176    if let Some(env_dir) = claude_config_dir_from_env() {
177        if let Some(path) = derive_mcp_path_from_override(&env_dir) {
178            return path;
179        }
180    }
181    if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
182        if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
183            return path;
184        }
185    }
186    get_default_claude_mcp_path()
187}
188
189/// 获取 Claude Code 主配置文件路径
190pub fn get_claude_settings_path() -> PathBuf {
191    let dir = get_claude_config_dir();
192    let settings = dir.join("settings.json");
193    if settings.exists() {
194        return settings;
195    }
196    // 兼容旧版命名:若存在旧文件则继续使用
197    let legacy = dir.join("claude.json");
198    if legacy.exists() {
199        return legacy;
200    }
201    // 默认新建:回落到标准文件名 settings.json(不再生成 claude.json)
202    settings
203}
204
205/// 获取应用配置目录路径(默认 $HOME/.cc-switch-tui)
206///
207/// Priority: CC_SWITCH_TUI_CONFIG_DIR > CC_SWITCH_CONFIG_DIR (deprecated) > default
208pub fn get_app_config_dir() -> PathBuf {
209    // New env var — takes priority
210    if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
211        let custom = PathBuf::from(custom);
212        if !custom.to_string_lossy().trim().is_empty() {
213            migrate_legacy_config_dir_once();
214            return expand_tilde(custom);
215        }
216    }
217
218    // Legacy env var — still works but prints deprecation warning
219    if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") {
220        let custom = PathBuf::from(custom);
221        if custom.to_string_lossy().trim().is_empty() {
222            return home_dir()
223                .expect("无法获取用户主目录")
224                .join(".cc-switch-tui");
225        }
226        eprintln!("deprecated: CC_SWITCH_CONFIG_DIR is set; use CC_SWITCH_TUI_CONFIG_DIR instead");
227        return expand_tilde(custom);
228    }
229
230    // CLI mode: no app store override, always use default
231    // if let Some(custom) = crate::app_store::get_app_config_dir_override() {
232    //     return custom;
233    // }
234
235    let path = home_dir()
236        .expect("无法获取用户主目录")
237        .join(".cc-switch-tui");
238
239    // 一次性迁移老旧 ~/.cc-switch/ → 当前应用配置目录。
240    // 嵌入 get_app_config_dir 内部,杜绝"新路径先于迁移创建"窗口。
241    migrate_legacy_config_dir_once();
242
243    path
244}
245
246/// 获取应用配置文件路径
247pub fn get_app_config_path() -> PathBuf {
248    get_app_config_dir().join("config.json")
249}
250
251/// 清理供应商名称,确保文件名安全
252pub fn sanitize_provider_name(name: &str) -> String {
253    name.chars()
254        .map(|c| match c {
255            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
256            _ => c,
257        })
258        .collect::<String>()
259        .to_lowercase()
260}
261
262/// 获取供应商配置文件路径
263pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
264    let base_name = provider_name
265        .map(sanitize_provider_name)
266        .unwrap_or_else(|| sanitize_provider_name(provider_id));
267
268    get_claude_config_dir().join(format!("settings-{base_name}.json"))
269}
270
271/// 读取 JSON 配置文件
272pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
273    if !path.exists() {
274        return Err(AppError::Config(format!("文件不存在: {}", path.display())));
275    }
276
277    let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
278
279    serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
280}
281
282/// 写入 JSON 配置文件
283pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
284    // 确保目录存在
285    if let Some(parent) = path.parent() {
286        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
287    }
288
289    let json =
290        serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
291
292    atomic_write(path, json.as_bytes())
293}
294
295/// 原子写入文本文件(用于 TOML/纯文本)
296pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
297    if let Some(parent) = path.parent() {
298        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
299    }
300    atomic_write(path, data.as_bytes())
301}
302
303/// 原子写入:写入临时文件后 rename 替换,避免半写状态
304pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
305    if let Some(parent) = path.parent() {
306        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
307    }
308
309    let parent = path
310        .parent()
311        .ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
312    let mut tmp = parent.to_path_buf();
313    let file_name = path
314        .file_name()
315        .ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
316        .to_string_lossy()
317        .to_string();
318    let ts = std::time::SystemTime::now()
319        .duration_since(std::time::UNIX_EPOCH)
320        .unwrap_or_default()
321        .as_nanos();
322    tmp.push(format!("{file_name}.tmp.{ts}"));
323
324    {
325        let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
326        f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
327        f.flush().map_err(|e| AppError::io(&tmp, e))?;
328    }
329
330    #[cfg(unix)]
331    {
332        use std::os::unix::fs::PermissionsExt;
333        if let Ok(meta) = fs::metadata(path) {
334            let perm = meta.permissions().mode();
335            let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
336        }
337    }
338
339    #[cfg(windows)]
340    {
341        // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
342        if path.exists() {
343            let _ = fs::remove_file(path);
344        }
345        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
346            context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
347            source: e,
348        })?;
349    }
350
351    #[cfg(not(windows))]
352    {
353        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
354            context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
355            source: e,
356        })?;
357    }
358    Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
365    use std::ffi::OsString;
366
367    struct ConfigDirEnvGuard {
368        key: String,
369        original: Option<OsString>,
370    }
371
372    impl ConfigDirEnvGuard {
373        fn new(key: &str, value: Option<&str>) -> Self {
374            let original = env::var_os(key);
375            match value {
376                Some(v) => unsafe { env::set_var(key, v) },
377                None => unsafe { env::remove_var(key) },
378            }
379            Self {
380                key: key.to_string(),
381                original,
382            }
383        }
384    }
385
386    impl Drop for ConfigDirEnvGuard {
387        fn drop(&mut self) {
388            match self.original.as_ref() {
389                Some(value) => unsafe { env::set_var(&self.key, value) },
390                None => unsafe { env::remove_var(&self.key) },
391            }
392        }
393    }
394
395    struct SettingsGuard {
396        original: crate::settings::AppSettings,
397    }
398
399    impl SettingsGuard {
400        fn with_claude_config_dir(dir: Option<&str>) -> Self {
401            let original = crate::settings::get_settings();
402            let mut settings = original.clone();
403            settings.claude_config_dir = dir.map(str::to_string);
404            crate::settings::update_settings(settings).unwrap();
405            Self { original }
406        }
407    }
408
409    impl Drop for SettingsGuard {
410        fn drop(&mut self) {
411            let _ = crate::settings::update_settings(self.original.clone());
412        }
413    }
414
415    #[test]
416    fn derive_mcp_path_from_override_preserves_folder_name() {
417        let override_dir = PathBuf::from("/tmp/profile/.claude");
418        let derived = derive_mcp_path_from_override(&override_dir)
419            .expect("should derive path for nested dir");
420        assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
421    }
422
423    #[test]
424    fn derive_mcp_path_from_override_handles_non_hidden_folder() {
425        let override_dir = PathBuf::from("/data/claude-config");
426        let derived = derive_mcp_path_from_override(&override_dir)
427            .expect("should derive path for standard dir");
428        assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
429    }
430
431    #[test]
432    fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
433        let override_dir = PathBuf::from("claude");
434        let derived = derive_mcp_path_from_override(&override_dir)
435            .expect("should derive path for single segment");
436        assert_eq!(derived, PathBuf::from("claude.json"));
437    }
438
439    #[test]
440    fn derive_mcp_path_from_root_like_dir_returns_none() {
441        let override_dir = PathBuf::from("/");
442        assert!(derive_mcp_path_from_override(&override_dir).is_none());
443    }
444
445    #[test]
446    fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
447        let _guard = lock_test_home_and_settings();
448        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
449        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
450        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
451
452        assert_eq!(
453            get_app_config_dir(),
454            PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
455        );
456
457        set_test_home_override(None);
458    }
459
460    #[test]
461    fn get_app_config_dir_uses_env_override_when_set() {
462        let _guard = lock_test_home_and_settings();
463        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
464        let _old = ConfigDirEnvGuard::new(
465            "CC_SWITCH_CONFIG_DIR",
466            Some("/tmp/cc-switch-config-override"),
467        );
468        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
469
470        assert_eq!(
471            get_app_config_dir(),
472            PathBuf::from("/tmp/cc-switch-config-override")
473        );
474
475        set_test_home_override(None);
476    }
477
478    #[test]
479    fn get_app_config_dir_ignores_blank_env_override() {
480        let _guard = lock_test_home_and_settings();
481        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
482        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("   "));
483        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
484
485        assert_eq!(
486            get_app_config_dir(),
487            PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
488        );
489
490        set_test_home_override(None);
491    }
492
493    #[test]
494    fn get_app_config_dir_prefers_new_env_var() {
495        let _guard = lock_test_home_and_settings();
496        let _new =
497            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
498        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
499        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
500
501        assert_eq!(
502            get_app_config_dir(),
503            PathBuf::from("/tmp/cc-switch-tui-new")
504        );
505
506        set_test_home_override(None);
507    }
508
509    #[test]
510    fn get_app_config_dir_new_env_var_alone_works() {
511        let _guard = lock_test_home_and_settings();
512        let _new =
513            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
514        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
515        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
516
517        assert_eq!(
518            get_app_config_dir(),
519            PathBuf::from("/tmp/cc-switch-tui-alone")
520        );
521
522        set_test_home_override(None);
523    }
524
525    #[test]
526    fn get_app_config_dir_expands_tilde_in_new_env_var() {
527        let _guard = lock_test_home_and_settings();
528        let _new =
529            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
530        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
531        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
532
533        assert_eq!(
534            get_app_config_dir(),
535            PathBuf::from("/tmp/cc-switch-home-tilde")
536                .join(".config")
537                .join("cc-switch-tui")
538        );
539
540        set_test_home_override(None);
541    }
542
543    #[test]
544    fn get_claude_config_dir_expands_tilde_in_env_var() {
545        let _guard = lock_test_home_and_settings();
546        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
547        set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
548
549        assert_eq!(
550            get_claude_config_dir(),
551            PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
552        );
553
554        set_test_home_override(None);
555    }
556
557    #[test]
558    fn get_claude_config_dir_respects_env_var() {
559        let _guard = lock_test_home_and_settings();
560        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
561        set_test_home_override(Some(Path::new("/tmp/claude-home")));
562
563        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
564
565        set_test_home_override(None);
566    }
567
568    #[test]
569    fn get_claude_mcp_path_respects_env_var() {
570        let _guard = lock_test_home_and_settings();
571        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
572        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
573        set_test_home_override(Some(Path::new("/tmp/claude-home")));
574
575        assert_eq!(
576            get_claude_mcp_path(),
577            PathBuf::from("/tmp").join("claude-custom.json")
578        );
579
580        set_test_home_override(None);
581    }
582
583    #[test]
584    fn get_claude_mcp_path_expands_tilde_in_env_var() {
585        let _guard = lock_test_home_and_settings();
586        let _settings = SettingsGuard::with_claude_config_dir(None);
587        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
588        set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
589
590        assert_eq!(
591            get_claude_mcp_path(),
592            PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom.json")
593        );
594
595        set_test_home_override(None);
596    }
597
598    #[test]
599    fn get_claude_mcp_path_blank_env_falls_back_to_settings() {
600        let _guard = lock_test_home_and_settings();
601        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
602        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
603        set_test_home_override(Some(Path::new("/tmp/claude-home")));
604
605        assert_eq!(
606            get_claude_mcp_path(),
607            PathBuf::from("/tmp").join("settings-override.json")
608        );
609
610        set_test_home_override(None);
611    }
612
613    #[test]
614    fn get_claude_config_dir_ignores_blank_env_var() {
615        let _guard = lock_test_home_and_settings();
616        let _settings = SettingsGuard::with_claude_config_dir(None);
617        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
618        set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
619
620        assert_eq!(
621            get_claude_config_dir(),
622            PathBuf::from("/tmp/claude-home-blank").join(".claude")
623        );
624
625        set_test_home_override(None);
626    }
627
628    #[test]
629    fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
630        let _guard = lock_test_home_and_settings();
631        let _settings = SettingsGuard::with_claude_config_dir(None);
632        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
633        set_test_home_override(Some(Path::new("/tmp/default-home")));
634
635        assert_eq!(
636            get_claude_config_dir(),
637            PathBuf::from("/tmp/default-home").join(".claude")
638        );
639
640        set_test_home_override(None);
641    }
642
643    #[test]
644    fn get_claude_config_dir_env_overrides_settings() {
645        let _guard = lock_test_home_and_settings();
646        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
647        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
648        set_test_home_override(Some(Path::new("/tmp/home")));
649
650        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
651
652        set_test_home_override(None);
653    }
654
655    #[test]
656    fn get_claude_config_dir_blank_env_falls_back_to_settings() {
657        let _guard = lock_test_home_and_settings();
658        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
659        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
660        set_test_home_override(Some(Path::new("/tmp/home")));
661
662        assert_eq!(
663            get_claude_config_dir(),
664            PathBuf::from("/tmp/settings-override")
665        );
666
667        set_test_home_override(None);
668    }
669
670    // ──── migration tests ────
671
672    #[test]
673    fn migration_copies_config_json_and_db() {
674        let _guard = lock_test_home_and_settings();
675        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
676        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
677
678        let temp = tempfile::tempdir().expect("create temp dir");
679        let home = temp.path();
680        set_test_home_override(Some(home));
681
682        let old_dir = home.join(".cc-switch");
683        let new_dir = home.join(".cc-switch-tui");
684        let marker = new_dir.join(".migrated-from-cc-switch");
685
686        fs::create_dir_all(old_dir.join("skills")).unwrap();
687        fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
688        fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
689        fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
690
691        migrate_legacy_config_dir_if_needed();
692
693        assert!(
694            new_dir.join("config.json").exists(),
695            "config.json should be copied"
696        );
697        assert!(
698            new_dir.join("cc-switch.db").exists(),
699            "cc-switch.db should be copied"
700        );
701        assert!(
702            new_dir.join("skills").join("my-skill.md").exists(),
703            "skills/ should be recursively copied"
704        );
705        assert!(marker.exists(), "migration marker should be written");
706
707        set_test_home_override(None);
708    }
709
710    #[test]
711    fn migration_skips_when_target_has_marker() {
712        let _guard = lock_test_home_and_settings();
713        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
714        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
715
716        let temp = tempfile::tempdir().expect("create temp dir");
717        let home = temp.path();
718        set_test_home_override(Some(home));
719
720        let old_dir = home.join(".cc-switch");
721        let new_dir = home.join(".cc-switch-tui");
722        let marker = new_dir.join(".migrated-from-cc-switch");
723
724        fs::create_dir_all(&old_dir).unwrap();
725        fs::write(old_dir.join("config.json"), "v1").unwrap();
726        fs::create_dir_all(&new_dir).unwrap();
727        fs::write(&marker, "already migrated").unwrap();
728
729        migrate_legacy_config_dir_if_needed();
730
731        assert!(
732            !new_dir.join("config.json").exists(),
733            "should not copy when marker exists"
734        );
735
736        set_test_home_override(None);
737    }
738
739    #[test]
740    fn migration_skips_when_legacy_env_override_set() {
741        let _guard = lock_test_home_and_settings();
742        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
743        let _old =
744            ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
745
746        let temp = tempfile::tempdir().expect("create temp dir");
747        let home = temp.path();
748        set_test_home_override(Some(home));
749
750        let old_dir = home.join(".cc-switch");
751        let new_dir = home.join(".cc-switch-tui");
752
753        fs::create_dir_all(&old_dir).unwrap();
754        fs::write(old_dir.join("config.json"), "v1").unwrap();
755
756        migrate_legacy_config_dir_if_needed();
757
758        assert!(
759            !new_dir.exists(),
760            "should not create target dir when env override set"
761        );
762
763        set_test_home_override(None);
764    }
765
766    #[test]
767    fn migration_uses_new_env_override_as_target() {
768        let _guard = lock_test_home_and_settings();
769        let _tui =
770            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
771        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
772
773        let temp = tempfile::tempdir().expect("create temp dir");
774        let home = temp.path();
775        set_test_home_override(Some(home));
776
777        let old_dir = home.join(".cc-switch");
778        let new_dir = home.join(".config").join("cc-switch-tui");
779        let marker = new_dir.join(".migrated-from-cc-switch");
780
781        fs::create_dir_all(&old_dir).unwrap();
782        fs::write(old_dir.join("config.json"), "v1").unwrap();
783
784        assert_eq!(
785            legacy_config_migration_paths(),
786            Some((old_dir.clone(), new_dir.clone()))
787        );
788
789        migrate_legacy_config_dir_if_needed();
790
791        assert!(new_dir.join("config.json").exists());
792        assert!(marker.exists());
793
794        set_test_home_override(None);
795    }
796
797    #[test]
798    fn migration_skips_when_target_already_has_contents() {
799        let _guard = lock_test_home_and_settings();
800        let _tui =
801            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
802        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
803
804        let temp = tempfile::tempdir().expect("create temp dir");
805        let home = temp.path();
806        set_test_home_override(Some(home));
807
808        let old_dir = home.join(".cc-switch");
809        let new_dir = home.join(".config").join("cc-switch-tui");
810
811        fs::create_dir_all(&old_dir).unwrap();
812        fs::write(old_dir.join("config.json"), "legacy").unwrap();
813        fs::create_dir_all(&new_dir).unwrap();
814        fs::write(new_dir.join("config.json"), "current").unwrap();
815
816        assert_eq!(legacy_config_migration_paths(), None);
817
818        migrate_legacy_config_dir_if_needed();
819
820        assert_eq!(
821            fs::read_to_string(new_dir.join("config.json")).unwrap(),
822            "current"
823        );
824        assert!(!new_dir.join(".migrated-from-cc-switch").exists());
825
826        set_test_home_override(None);
827    }
828
829    #[test]
830    fn migration_copies_settings_json_when_target_only_has_db() {
831        let _guard = lock_test_home_and_settings();
832        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
833        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
834
835        let temp = tempfile::tempdir().expect("create temp dir");
836        let home = temp.path();
837        set_test_home_override(Some(home));
838
839        let old_dir = home.join(".cc-switch");
840        let new_dir = home.join(".cc-switch-tui");
841
842        fs::create_dir_all(&old_dir).unwrap();
843        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
844        fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
845        fs::create_dir_all(&new_dir).unwrap();
846        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
847
848        assert_eq!(
849            legacy_config_migration_paths(),
850            Some((old_dir.clone(), new_dir.clone()))
851        );
852
853        migrate_legacy_config_dir_if_needed();
854
855        assert_eq!(
856            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
857            "legacy-settings"
858        );
859        assert_eq!(
860            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
861            "current-db",
862            "existing target database must not be overwritten"
863        );
864        assert!(new_dir.join(".migrated-from-cc-switch").exists());
865
866        set_test_home_override(None);
867    }
868
869    #[test]
870    fn migration_repairs_missing_settings_json_after_success_marker() {
871        let _guard = lock_test_home_and_settings();
872        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
873        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
874
875        let temp = tempfile::tempdir().expect("create temp dir");
876        let home = temp.path();
877        set_test_home_override(Some(home));
878
879        let old_dir = home.join(".cc-switch");
880        let new_dir = home.join(".cc-switch-tui");
881        let marker = new_dir.join(".migrated-from-cc-switch");
882
883        fs::create_dir_all(&old_dir).unwrap();
884        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
885        fs::create_dir_all(&new_dir).unwrap();
886        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
887        fs::write(&marker, "Migrated from old path").unwrap();
888
889        assert_eq!(
890            legacy_config_migration_paths(),
891            None,
892            "already-migrated directories should not prompt again"
893        );
894
895        migrate_legacy_config_dir_if_needed();
896
897        assert_eq!(
898            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
899            "legacy-settings"
900        );
901        assert_eq!(
902            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
903            "current-db",
904            "repair must not overwrite the existing target database"
905        );
906
907        set_test_home_override(None);
908    }
909
910    #[test]
911    fn migration_replaces_generated_target_settings_with_legacy_preferences() {
912        let _guard = lock_test_home_and_settings();
913        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
914        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
915
916        let temp = tempfile::tempdir().expect("create temp dir");
917        let home = temp.path();
918        set_test_home_override(Some(home));
919
920        let old_dir = home.join(".cc-switch");
921        let new_dir = home.join(".cc-switch-tui");
922
923        fs::create_dir_all(&old_dir).unwrap();
924        fs::write(
925            old_dir.join("settings.json"),
926            r#"{
927                "language": "zh",
928                "visibleApps": {
929                    "claude": true,
930                    "codex": true,
931                    "gemini": true,
932                    "opencode": true,
933                    "openclaw": true,
934                    "hermes": true
935                },
936                "currentProviderClaude": "legacy-current"
937            }"#,
938        )
939        .unwrap();
940        fs::create_dir_all(&new_dir).unwrap();
941        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
942        fs::write(
943            new_dir.join("settings.json"),
944            r#"{
945                "language": "en",
946                "visibleApps": {
947                    "claude": true,
948                    "codex": true,
949                    "gemini": false,
950                    "opencode": true,
951                    "openclaw": false,
952                    "hermes": false
953                }
954            }"#,
955        )
956        .unwrap();
957
958        assert_eq!(
959            legacy_config_migration_paths(),
960            Some((old_dir.clone(), new_dir.clone()))
961        );
962
963        migrate_legacy_config_dir_if_needed();
964
965        let migrated_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
966        let value: serde_json::Value = serde_json::from_str(&migrated_settings).unwrap();
967        assert_eq!(value["language"], "zh");
968        assert_eq!(value["visibleApps"]["gemini"], true);
969        assert_eq!(value["visibleApps"]["openclaw"], true);
970        assert_eq!(value["visibleApps"]["hermes"], true);
971        assert_eq!(value["currentProviderClaude"], "legacy-current");
972        assert_eq!(
973            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
974            "current-db",
975            "existing target database must not be overwritten"
976        );
977        assert!(new_dir.join(".migrated-from-cc-switch").exists());
978
979        set_test_home_override(None);
980    }
981
982    #[test]
983    fn migration_does_not_replace_configured_target_settings() {
984        let _guard = lock_test_home_and_settings();
985        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
986        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
987
988        let temp = tempfile::tempdir().expect("create temp dir");
989        let home = temp.path();
990        set_test_home_override(Some(home));
991
992        let old_dir = home.join(".cc-switch");
993        let new_dir = home.join(".cc-switch-tui");
994
995        fs::create_dir_all(&old_dir).unwrap();
996        fs::write(
997            old_dir.join("settings.json"),
998            r#"{
999                "language": "zh",
1000                "visibleApps": {
1001                    "claude": true,
1002                    "codex": true,
1003                    "gemini": true,
1004                    "opencode": true,
1005                    "openclaw": true,
1006                    "hermes": true
1007                }
1008            }"#,
1009        )
1010        .unwrap();
1011        fs::create_dir_all(&new_dir).unwrap();
1012        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
1013        fs::write(
1014            new_dir.join("settings.json"),
1015            r#"{
1016                "language": "zh",
1017                "webdavSync": {
1018                    "enabled": true,
1019                    "baseUrl": "https://dav.example.com",
1020                    "username": "user",
1021                    "password": "pass"
1022                },
1023                "visibleApps": {
1024                    "claude": true,
1025                    "codex": true,
1026                    "gemini": false,
1027                    "opencode": true,
1028                    "openclaw": false,
1029                    "hermes": false
1030                }
1031            }"#,
1032        )
1033        .unwrap();
1034
1035        assert_eq!(
1036            legacy_config_migration_paths(),
1037            None,
1038            "configured target settings should block implicit repair"
1039        );
1040
1041        migrate_legacy_config_dir_if_needed();
1042
1043        let target_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
1044        let value: serde_json::Value = serde_json::from_str(&target_settings).unwrap();
1045        assert_eq!(value["webdavSync"]["enabled"], true);
1046        assert_eq!(value["visibleApps"]["gemini"], false);
1047        assert!(
1048            !new_dir.join(".migrated-from-cc-switch").exists(),
1049            "blocked repair must not write the migration marker"
1050        );
1051
1052        set_test_home_override(None);
1053    }
1054
1055    #[test]
1056    fn migration_is_idempotent() {
1057        let _guard = lock_test_home_and_settings();
1058        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1059        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1060
1061        let temp = tempfile::tempdir().expect("create temp dir");
1062        let home = temp.path();
1063        set_test_home_override(Some(home));
1064
1065        let old_dir = home.join(".cc-switch");
1066        let new_dir = home.join(".cc-switch-tui");
1067        let marker = new_dir.join(".migrated-from-cc-switch");
1068
1069        fs::create_dir_all(&old_dir).unwrap();
1070        fs::write(old_dir.join("config.json"), "v1").unwrap();
1071
1072        // First run
1073        migrate_legacy_config_dir_if_needed();
1074        assert!(new_dir.join("config.json").exists());
1075        let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
1076
1077        // Second run — should be a no-op
1078        migrate_legacy_config_dir_if_needed();
1079        let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
1080        assert_eq!(
1081            mtime_after_first, mtime_after_second,
1082            "second migration should not overwrite marker"
1083        );
1084
1085        set_test_home_override(None);
1086    }
1087
1088    #[test]
1089    fn migration_preserves_old_directory() {
1090        let _guard = lock_test_home_and_settings();
1091        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1092        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1093
1094        let temp = tempfile::tempdir().expect("create temp dir");
1095        let home = temp.path();
1096        set_test_home_override(Some(home));
1097
1098        let old_dir = home.join(".cc-switch");
1099
1100        fs::create_dir_all(&old_dir).unwrap();
1101        fs::write(old_dir.join("config.json"), "v1").unwrap();
1102
1103        migrate_legacy_config_dir_if_needed();
1104
1105        assert!(old_dir.exists(), "old directory must be preserved");
1106        assert!(
1107            old_dir.join("config.json").exists(),
1108            "old files must be preserved"
1109        );
1110
1111        set_test_home_override(None);
1112    }
1113
1114    #[test]
1115    fn migration_copies_only_3_most_recent_backups() {
1116        let _guard = lock_test_home_and_settings();
1117        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1118        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1119
1120        let temp = tempfile::tempdir().expect("create temp dir");
1121        let home = temp.path();
1122        set_test_home_override(Some(home));
1123
1124        let old_dir = home.join(".cc-switch");
1125        let backup_dir = old_dir.join("backups");
1126        fs::create_dir_all(&backup_dir).unwrap();
1127
1128        // Create 5 backup files with increasing mtime
1129        for i in 1..=5 {
1130            let path = backup_dir.join(format!("backup-{}.json", i));
1131            fs::write(&path, format!("backup {}", i)).unwrap();
1132            std::thread::sleep(std::time::Duration::from_millis(10));
1133        }
1134
1135        migrate_legacy_config_dir_if_needed();
1136
1137        let new_backup_dir = home.join(".cc-switch-tui").join("backups");
1138        let copied: Vec<_> = fs::read_dir(&new_backup_dir)
1139            .unwrap()
1140            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1141            .collect();
1142
1143        assert_eq!(
1144            copied.len(),
1145            3,
1146            "only 3 most recent backups should be copied"
1147        );
1148        assert!(
1149            copied.contains(&"backup-3.json".to_string()),
1150            "third most recent should be copied"
1151        );
1152        assert!(
1153            copied.contains(&"backup-4.json".to_string()),
1154            "second most recent should be copied"
1155        );
1156        assert!(
1157            copied.contains(&"backup-5.json".to_string()),
1158            "most recent should be copied"
1159        );
1160
1161        set_test_home_override(None);
1162    }
1163}
1164
1165/// 复制文件
1166pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
1167    fs::copy(from, to).map_err(|e| AppError::IoContext {
1168        context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
1169        source: e,
1170    })?;
1171    Ok(())
1172}
1173
1174/// 删除文件
1175pub fn delete_file(path: &Path) -> Result<(), AppError> {
1176    if path.exists() {
1177        fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
1178    }
1179    Ok(())
1180}
1181
1182/// 递归复制目录内容(跳过软链接)
1183fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
1184    fs::create_dir_all(dst)?;
1185    for entry in fs::read_dir(src)? {
1186        let entry = entry?;
1187        let file_type = entry.file_type()?;
1188        if file_type.is_symlink() {
1189            continue;
1190        }
1191        let src_path = entry.path();
1192        let dst_path = dst.join(entry.file_name());
1193        if file_type.is_dir() {
1194            copy_dir_recursive(&src_path, &dst_path)?;
1195        } else if !dst_path.exists() {
1196            fs::copy(&src_path, &dst_path)?;
1197        }
1198    }
1199    Ok(())
1200}
1201
1202/// 复制备份目录中最近 3 个(按修改时间)条目
1203fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
1204    let mut entries: Vec<_> = fs::read_dir(src)?
1205        .filter_map(|e| e.ok())
1206        .filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
1207        .collect();
1208    entries.sort_by_key(|e| {
1209        e.metadata()
1210            .and_then(|m| m.modified())
1211            .unwrap_or(std::time::UNIX_EPOCH)
1212    });
1213    entries.reverse();
1214    entries.truncate(limit);
1215
1216    fs::create_dir_all(dst)?;
1217    for entry in entries {
1218        let src_path = entry.path();
1219        let dst_path = dst.join(entry.file_name());
1220        if entry.file_type().map_or(false, |t| t.is_dir()) {
1221            copy_dir_recursive(&src_path, &dst_path)?;
1222        } else if !dst_path.exists() {
1223            fs::copy(&src_path, &dst_path)?;
1224        }
1225    }
1226    Ok(())
1227}
1228
1229fn target_allows_legacy_migration(old_dir: &Path, new_dir: &Path) -> bool {
1230    if !new_dir.exists() {
1231        return true;
1232    }
1233    if !new_dir.is_dir() {
1234        return false;
1235    }
1236
1237    let entries = match fs::read_dir(new_dir) {
1238        Ok(entries) => entries,
1239        Err(_) => return false,
1240    };
1241
1242    for entry in entries {
1243        let Ok(entry) = entry else {
1244            return false;
1245        };
1246        let file_name = entry.file_name();
1247        if file_name == "cc-switch.db" {
1248            continue;
1249        }
1250        if file_name == "settings.json"
1251            && legacy_settings_should_replace_target(
1252                &old_dir.join("settings.json"),
1253                &new_dir.join("settings.json"),
1254            )
1255        {
1256            continue;
1257        }
1258        return false;
1259    }
1260    true
1261}
1262
1263fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
1264    ["settings.json", "config.json"]
1265        .iter()
1266        .any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
1267        || legacy_settings_should_replace_target(
1268            &old_dir.join("settings.json"),
1269            &new_dir.join("settings.json"),
1270        )
1271}
1272
1273fn migration_marker_allows_repair(marker: &Path) -> bool {
1274    match fs::read_to_string(marker) {
1275        Ok(content) => content.starts_with("Migrated from "),
1276        Err(_) => false,
1277    }
1278}
1279
1280/// 提取迁移前置检查逻辑,返回 (old_dir, new_dir, marker) 若条件满足,否则 None。
1281fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
1282    let home = home_dir()?;
1283    let old_dir = home.join(".cc-switch");
1284    let new_dir = effective_app_config_dir_without_migration(&home)?;
1285    let marker = new_dir.join(".migrated-from-cc-switch");
1286
1287    if old_dir == new_dir {
1288        return None;
1289    }
1290    if !old_dir.exists() || !old_dir.is_dir() {
1291        return None;
1292    }
1293    if marker.exists() {
1294        if !allow_repair
1295            || !migration_marker_allows_repair(&marker)
1296            || !needs_legacy_json_repair(&old_dir, &new_dir)
1297        {
1298            return None;
1299        }
1300    }
1301    let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
1302    if !has_contents {
1303        return None;
1304    }
1305    if !marker.exists() && !target_allows_legacy_migration(&old_dir, &new_dir) {
1306        return None;
1307    }
1308
1309    Some((old_dir, new_dir, marker))
1310}
1311
1312/// 返回待迁移的旧配置目录和当前配置目录。
1313pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1314    migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1315}
1316
1317/// 检查是否存在尚未迁移的旧版配置目录。
1318///
1319/// 返回 true 表示 ~/.cc-switch/ 存在且未迁移,应提示用户确认。
1320pub fn check_legacy_config_dir_migration_needed() -> bool {
1321    legacy_config_migration_paths().is_some()
1322}
1323
1324/// 用户拒绝迁移:写入标记文件以永不再次提示。
1325///
1326/// 错误仅记录到 stderr,绝不阻塞启动。
1327pub fn skip_legacy_config_dir_migration() {
1328    let (_, new_dir, marker) = match migration_guard(false) {
1329        Some(v) => v,
1330        None => return,
1331    };
1332
1333    if let Err(e) = std::fs::create_dir_all(&new_dir)
1334        .and_then(|_| std::fs::write(&marker, "User declined migration"))
1335    {
1336        eprintln!(
1337            "cc-switch: failed to write skip-migration marker at {}: {e}",
1338            marker.display()
1339        );
1340    }
1341}
1342
1343/// 首次运行时自动将旧版 ~/.cc-switch/ 迁移到 ~/.cc-switch-tui/
1344///
1345/// 仅在以下条件全部满足时执行:
1346/// - 未设置 CC_SWITCH_TUI_CONFIG_DIR 或 CC_SWITCH_CONFIG_DIR 环境变量
1347/// - 旧目录 ~/.cc-switch/ 存在且非空
1348/// - 目标目录不存在 .migrated-from-cc-switch 标记文件
1349///
1350/// 非破坏性:旧目录完好保留。错误仅记录警告,绝不阻塞启动。
1351pub fn migrate_legacy_config_dir_if_needed() {
1352    let (old_dir, new_dir, marker) = match migration_guard(true) {
1353        Some(v) => v,
1354        None => return,
1355    };
1356
1357    // Perform migration (errors caught, never propagate)
1358    if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
1359        eprintln!(
1360            "cc-switch: legacy config migration failed: {e} (old data preserved at {})",
1361            old_dir.display()
1362        );
1363    }
1364}
1365
1366fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
1367    fs::create_dir_all(new_dir)?;
1368
1369    for entry in fs::read_dir(old_dir)? {
1370        let entry = entry?;
1371        let file_type = entry.file_type()?;
1372        if file_type.is_symlink() {
1373            continue;
1374        }
1375        let src_path = entry.path();
1376        let file_name = entry.file_name();
1377        let dst_path = new_dir.join(&file_name);
1378
1379        if file_name == "backups" && file_type.is_dir() {
1380            copy_recent_backups(&src_path, &dst_path, 3)?;
1381        } else if file_type.is_dir() {
1382            copy_dir_recursive(&src_path, &dst_path)?;
1383        } else if file_name == "settings.json"
1384            && legacy_settings_should_replace_target(&src_path, &dst_path)
1385        {
1386            fs::copy(&src_path, &dst_path)?;
1387        } else if !dst_path.exists() {
1388            fs::copy(&src_path, &dst_path)?;
1389        }
1390    }
1391
1392    // Write marker file to prevent re-migration
1393    fs::write(
1394        marker,
1395        format!(
1396            "Migrated from {} on {}",
1397            old_dir.display(),
1398            chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1399        ),
1400    )?;
1401
1402    eprintln!(
1403        "cc-switch: config migrated from {} to {} (old directory preserved)",
1404        old_dir.display(),
1405        new_dir.display()
1406    );
1407    Ok(())
1408}
1409
1410fn legacy_settings_should_replace_target(legacy_path: &Path, target_path: &Path) -> bool {
1411    if !legacy_path.is_file() {
1412        return false;
1413    }
1414    if !target_path.exists() {
1415        return true;
1416    }
1417
1418    let Ok(legacy) = read_json_value(legacy_path) else {
1419        return false;
1420    };
1421    let Ok(target) = read_json_value(target_path) else {
1422        return false;
1423    };
1424
1425    legacy_settings_has_migration_preference(&legacy)
1426        && target_settings_looks_generated(&target)
1427        && legacy_settings_are_more_specific(&legacy, &target)
1428}
1429
1430fn read_json_value(path: &Path) -> Result<serde_json::Value, serde_json::Error> {
1431    let raw = fs::read_to_string(path).map_err(serde_json::Error::io)?;
1432    serde_json::from_str(&raw)
1433}
1434
1435fn legacy_settings_has_migration_preference(value: &serde_json::Value) -> bool {
1436    language_code(value).is_some_and(|lang| lang.eq_ignore_ascii_case("zh"))
1437        || visible_apps_enabled_count(value) > default_visible_apps_enabled_count()
1438        || value
1439            .get("currentProviderClaude")
1440            .or_else(|| value.get("currentProviderCodex"))
1441            .or_else(|| value.get("currentProviderGemini"))
1442            .or_else(|| value.get("currentProviderOpencode"))
1443            .or_else(|| value.get("currentProviderOpenclaw"))
1444            .or_else(|| value.get("currentProviderHermes"))
1445            .and_then(|value| value.as_str())
1446            .is_some_and(|value| !value.trim().is_empty())
1447        || value
1448            .pointer("/webdavSync/enabled")
1449            .and_then(|value| value.as_bool())
1450            .unwrap_or(false)
1451}
1452
1453fn target_settings_looks_generated(value: &serde_json::Value) -> bool {
1454    // Generated settings from a premature new-directory startup carry defaults,
1455    // but no current provider, WebDAV, or non-auto skill sync state.
1456    let language_is_default = language_code(value)
1457        .map(|lang| lang.eq_ignore_ascii_case("en"))
1458        .unwrap_or(true);
1459
1460    language_is_default
1461        && current_provider_fields_empty(value)
1462        && !value
1463            .pointer("/webdavSync/enabled")
1464            .and_then(|value| value.as_bool())
1465            .unwrap_or(false)
1466        && value
1467            .get("skillSyncMethod")
1468            .and_then(|value| value.as_str())
1469            .map(|method| method == "auto")
1470            .unwrap_or(true)
1471}
1472
1473fn legacy_settings_are_more_specific(
1474    legacy: &serde_json::Value,
1475    target: &serde_json::Value,
1476) -> bool {
1477    let language_is_more_specific = match (language_code(legacy), language_code(target)) {
1478        (Some(legacy), Some(target)) => !legacy.eq_ignore_ascii_case(target),
1479        (Some(_), None) => true,
1480        _ => false,
1481    };
1482
1483    language_is_more_specific
1484        || visible_apps_enabled_count(legacy) > visible_apps_enabled_count(target)
1485        || !current_provider_fields_empty(legacy)
1486        || legacy
1487            .pointer("/webdavSync/enabled")
1488            .and_then(|value| value.as_bool())
1489            .unwrap_or(false)
1490}
1491
1492fn current_provider_fields_empty(value: &serde_json::Value) -> bool {
1493    [
1494        "currentProviderClaude",
1495        "currentProviderCodex",
1496        "currentProviderGemini",
1497        "currentProviderOpencode",
1498        "currentProviderOpenclaw",
1499        "currentProviderHermes",
1500    ]
1501    .iter()
1502    .all(|key| {
1503        value
1504            .get(*key)
1505            .and_then(|value| value.as_str())
1506            .map(|value| value.trim().is_empty())
1507            .unwrap_or(true)
1508    })
1509}
1510
1511fn language_code(value: &serde_json::Value) -> Option<&str> {
1512    value.get("language").and_then(|value| value.as_str())
1513}
1514
1515fn visible_apps_enabled_count(value: &serde_json::Value) -> usize {
1516    value
1517        .get("visibleApps")
1518        .and_then(|value| value.as_object())
1519        .map(|apps| {
1520            apps.values()
1521                .filter(|value| value.as_bool().unwrap_or(false))
1522                .count()
1523        })
1524        .unwrap_or(default_visible_apps_enabled_count())
1525}
1526
1527fn default_visible_apps_enabled_count() -> usize {
1528    crate::settings::default_visible_apps()
1529        .ordered_enabled()
1530        .len()
1531}