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