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};
6
7use crate::error::AppError;
8
9pub(crate) fn home_dir() -> Option<PathBuf> {
10    #[cfg(test)]
11    if let Some(home) = crate::test_support::test_home_override() {
12        return Some(home);
13    }
14
15    dirs::home_dir()
16}
17
18fn migrate_legacy_config_dir_once() {
19    // AtomicBool guard: 进程内只跑一次,避免测试并发和重复 stat 调用
20    use std::sync::atomic::{AtomicBool, Ordering};
21    static MIGRATED: AtomicBool = AtomicBool::new(false);
22    if !MIGRATED.swap(true, Ordering::Relaxed) {
23        migrate_legacy_config_dir_if_needed();
24    }
25}
26
27/// If `path` starts with `~` / `~/`, replace the tilde with the home directory.
28/// Otherwise return the path unchanged.
29fn expand_tilde(path: PathBuf) -> PathBuf {
30    let lossy = path.to_string_lossy();
31    if lossy == "~" {
32        return home_dir().unwrap_or(path);
33    }
34
35    if let Some(rest) = lossy
36        .strip_prefix("~/")
37        .or_else(|| lossy.strip_prefix("~\\"))
38    {
39        if let Some(home) = home_dir() {
40            return home.join(rest);
41        }
42    }
43
44    path
45}
46
47/// 获取 Claude Code 配置目录路径
48///
49/// Priority: `CLAUDE_CONFIG_DIR` env var > cc-switch settings override > `$HOME/.claude`
50pub fn get_claude_config_dir() -> PathBuf {
51    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
52        let dir = PathBuf::from(dir);
53        if !dir.as_os_str().is_empty() && !dir.to_string_lossy().trim().is_empty() {
54            return expand_tilde(dir);
55        }
56    }
57    if let Some(custom) = crate::settings::get_claude_override_dir() {
58        return custom;
59    }
60
61    home_dir().expect("无法获取用户主目录").join(".claude")
62}
63
64/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
65pub fn get_default_claude_mcp_path() -> PathBuf {
66    home_dir().expect("无法获取用户主目录").join(".claude.json")
67}
68
69fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
70    let file_name = dir
71        .file_name()
72        .map(|name| name.to_string_lossy().to_string())?
73        .trim()
74        .to_string();
75    if file_name.is_empty() {
76        return None;
77    }
78    let parent = dir.parent().unwrap_or_else(|| Path::new(""));
79    Some(parent.join(format!("{file_name}.json")))
80}
81
82fn effective_app_config_dir_without_migration(home: &Path) -> Option<PathBuf> {
83    if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
84        let custom = PathBuf::from(custom);
85        if !custom.to_string_lossy().trim().is_empty() {
86            return Some(expand_tilde(custom));
87        }
88    }
89
90    if env::var_os("CC_SWITCH_CONFIG_DIR").is_some() {
91        return None;
92    }
93
94    Some(home.join(".cc-switch-tui"))
95}
96
97/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
98pub fn get_claude_mcp_path() -> PathBuf {
99    if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
100        if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
101            return path;
102        }
103    }
104    get_default_claude_mcp_path()
105}
106
107/// 获取 Claude Code 主配置文件路径
108pub fn get_claude_settings_path() -> PathBuf {
109    let dir = get_claude_config_dir();
110    let settings = dir.join("settings.json");
111    if settings.exists() {
112        return settings;
113    }
114    // 兼容旧版命名:若存在旧文件则继续使用
115    let legacy = dir.join("claude.json");
116    if legacy.exists() {
117        return legacy;
118    }
119    // 默认新建:回落到标准文件名 settings.json(不再生成 claude.json)
120    settings
121}
122
123/// 获取应用配置目录路径(默认 $HOME/.cc-switch-tui)
124///
125/// Priority: CC_SWITCH_TUI_CONFIG_DIR > CC_SWITCH_CONFIG_DIR (deprecated) > default
126pub fn get_app_config_dir() -> PathBuf {
127    // New env var — takes priority
128    if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
129        let custom = PathBuf::from(custom);
130        if !custom.to_string_lossy().trim().is_empty() {
131            migrate_legacy_config_dir_once();
132            return expand_tilde(custom);
133        }
134    }
135
136    // Legacy env var — still works but prints deprecation warning
137    if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") {
138        let custom = PathBuf::from(custom);
139        if custom.to_string_lossy().trim().is_empty() {
140            return home_dir()
141                .expect("无法获取用户主目录")
142                .join(".cc-switch-tui");
143        }
144        eprintln!("deprecated: CC_SWITCH_CONFIG_DIR is set; use CC_SWITCH_TUI_CONFIG_DIR instead");
145        return expand_tilde(custom);
146    }
147
148    // CLI mode: no app store override, always use default
149    // if let Some(custom) = crate::app_store::get_app_config_dir_override() {
150    //     return custom;
151    // }
152
153    let path = home_dir()
154        .expect("无法获取用户主目录")
155        .join(".cc-switch-tui");
156
157    // 一次性迁移老旧 ~/.cc-switch/ → 当前应用配置目录。
158    // 嵌入 get_app_config_dir 内部,杜绝"新路径先于迁移创建"窗口。
159    migrate_legacy_config_dir_once();
160
161    path
162}
163
164/// 获取应用配置文件路径
165pub fn get_app_config_path() -> PathBuf {
166    get_app_config_dir().join("config.json")
167}
168
169/// 清理供应商名称,确保文件名安全
170pub fn sanitize_provider_name(name: &str) -> String {
171    name.chars()
172        .map(|c| match c {
173            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
174            _ => c,
175        })
176        .collect::<String>()
177        .to_lowercase()
178}
179
180/// 获取供应商配置文件路径
181pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
182    let base_name = provider_name
183        .map(sanitize_provider_name)
184        .unwrap_or_else(|| sanitize_provider_name(provider_id));
185
186    get_claude_config_dir().join(format!("settings-{base_name}.json"))
187}
188
189/// 读取 JSON 配置文件
190pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
191    if !path.exists() {
192        return Err(AppError::Config(format!("文件不存在: {}", path.display())));
193    }
194
195    let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
196
197    serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
198}
199
200/// 写入 JSON 配置文件
201pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
202    // 确保目录存在
203    if let Some(parent) = path.parent() {
204        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
205    }
206
207    let json =
208        serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
209
210    atomic_write(path, json.as_bytes())
211}
212
213/// 原子写入文本文件(用于 TOML/纯文本)
214pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
215    if let Some(parent) = path.parent() {
216        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
217    }
218    atomic_write(path, data.as_bytes())
219}
220
221/// 原子写入:写入临时文件后 rename 替换,避免半写状态
222pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
223    if let Some(parent) = path.parent() {
224        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
225    }
226
227    let parent = path
228        .parent()
229        .ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
230    let mut tmp = parent.to_path_buf();
231    let file_name = path
232        .file_name()
233        .ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
234        .to_string_lossy()
235        .to_string();
236    let ts = std::time::SystemTime::now()
237        .duration_since(std::time::UNIX_EPOCH)
238        .unwrap_or_default()
239        .as_nanos();
240    tmp.push(format!("{file_name}.tmp.{ts}"));
241
242    {
243        let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
244        f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
245        f.flush().map_err(|e| AppError::io(&tmp, e))?;
246    }
247
248    #[cfg(unix)]
249    {
250        use std::os::unix::fs::PermissionsExt;
251        if let Ok(meta) = fs::metadata(path) {
252            let perm = meta.permissions().mode();
253            let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
254        }
255    }
256
257    #[cfg(windows)]
258    {
259        // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
260        if path.exists() {
261            let _ = fs::remove_file(path);
262        }
263        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
264            context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
265            source: e,
266        })?;
267    }
268
269    #[cfg(not(windows))]
270    {
271        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
272            context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
273            source: e,
274        })?;
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
283    use std::ffi::OsString;
284
285    struct ConfigDirEnvGuard {
286        key: String,
287        original: Option<OsString>,
288    }
289
290    impl ConfigDirEnvGuard {
291        fn new(key: &str, value: Option<&str>) -> Self {
292            let original = env::var_os(key);
293            match value {
294                Some(v) => unsafe { env::set_var(key, v) },
295                None => unsafe { env::remove_var(key) },
296            }
297            Self {
298                key: key.to_string(),
299                original,
300            }
301        }
302    }
303
304    impl Drop for ConfigDirEnvGuard {
305        fn drop(&mut self) {
306            match self.original.as_ref() {
307                Some(value) => unsafe { env::set_var(&self.key, value) },
308                None => unsafe { env::remove_var(&self.key) },
309            }
310        }
311    }
312
313    #[test]
314    fn derive_mcp_path_from_override_preserves_folder_name() {
315        let override_dir = PathBuf::from("/tmp/profile/.claude");
316        let derived = derive_mcp_path_from_override(&override_dir)
317            .expect("should derive path for nested dir");
318        assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
319    }
320
321    #[test]
322    fn derive_mcp_path_from_override_handles_non_hidden_folder() {
323        let override_dir = PathBuf::from("/data/claude-config");
324        let derived = derive_mcp_path_from_override(&override_dir)
325            .expect("should derive path for standard dir");
326        assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
327    }
328
329    #[test]
330    fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
331        let override_dir = PathBuf::from("claude");
332        let derived = derive_mcp_path_from_override(&override_dir)
333            .expect("should derive path for single segment");
334        assert_eq!(derived, PathBuf::from("claude.json"));
335    }
336
337    #[test]
338    fn derive_mcp_path_from_root_like_dir_returns_none() {
339        let override_dir = PathBuf::from("/");
340        assert!(derive_mcp_path_from_override(&override_dir).is_none());
341    }
342
343    #[test]
344    fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
345        let _guard = lock_test_home_and_settings();
346        let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
347        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
348
349        assert_eq!(
350            get_app_config_dir(),
351            PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
352        );
353
354        set_test_home_override(None);
355    }
356
357    #[test]
358    fn get_app_config_dir_uses_env_override_when_set() {
359        let _guard = lock_test_home_and_settings();
360        let _env = ConfigDirEnvGuard::new(
361            "CC_SWITCH_CONFIG_DIR",
362            Some("/tmp/cc-switch-config-override"),
363        );
364        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
365
366        assert_eq!(
367            get_app_config_dir(),
368            PathBuf::from("/tmp/cc-switch-config-override")
369        );
370
371        set_test_home_override(None);
372    }
373
374    #[test]
375    fn get_app_config_dir_ignores_blank_env_override() {
376        let _guard = lock_test_home_and_settings();
377        let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("   "));
378        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
379
380        assert_eq!(
381            get_app_config_dir(),
382            PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
383        );
384
385        set_test_home_override(None);
386    }
387
388    #[test]
389    fn get_app_config_dir_prefers_new_env_var() {
390        let _guard = lock_test_home_and_settings();
391        let _new =
392            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
393        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
394        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
395
396        assert_eq!(
397            get_app_config_dir(),
398            PathBuf::from("/tmp/cc-switch-tui-new")
399        );
400
401        set_test_home_override(None);
402    }
403
404    #[test]
405    fn get_app_config_dir_new_env_var_alone_works() {
406        let _guard = lock_test_home_and_settings();
407        let _new =
408            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
409        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
410        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
411
412        assert_eq!(
413            get_app_config_dir(),
414            PathBuf::from("/tmp/cc-switch-tui-alone")
415        );
416
417        set_test_home_override(None);
418    }
419
420    #[test]
421    fn get_app_config_dir_expands_tilde_in_new_env_var() {
422        let _guard = lock_test_home_and_settings();
423        let _new =
424            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
425        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
426        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
427
428        assert_eq!(
429            get_app_config_dir(),
430            PathBuf::from("/tmp/cc-switch-home-tilde")
431                .join(".config")
432                .join("cc-switch-tui")
433        );
434
435        set_test_home_override(None);
436    }
437
438    #[test]
439    fn get_claude_config_dir_expands_tilde_in_env_var() {
440        let _guard = lock_test_home_and_settings();
441        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
442        set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
443
444        assert_eq!(
445            get_claude_config_dir(),
446            PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
447        );
448
449        set_test_home_override(None);
450    }
451
452    #[test]
453    fn get_claude_config_dir_respects_env_var() {
454        let _guard = lock_test_home_and_settings();
455        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
456        set_test_home_override(Some(Path::new("/tmp/claude-home")));
457
458        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
459
460        set_test_home_override(None);
461    }
462
463    #[test]
464    fn get_claude_config_dir_ignores_blank_env_var() {
465        let _guard = lock_test_home_and_settings();
466        let original_settings = crate::settings::get_settings();
467        let mut settings = original_settings.clone();
468        settings.claude_config_dir = None;
469        crate::settings::update_settings(settings).unwrap();
470        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
471        set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
472
473        assert_eq!(
474            get_claude_config_dir(),
475            PathBuf::from("/tmp/claude-home-blank").join(".claude")
476        );
477
478        crate::settings::update_settings(original_settings).unwrap();
479        set_test_home_override(None);
480    }
481
482    #[test]
483    fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
484        let _guard = lock_test_home_and_settings();
485        let original_settings = crate::settings::get_settings();
486        let mut settings = original_settings.clone();
487        settings.claude_config_dir = None;
488        crate::settings::update_settings(settings).unwrap();
489        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
490        set_test_home_override(Some(Path::new("/tmp/default-home")));
491
492        assert_eq!(
493            get_claude_config_dir(),
494            PathBuf::from("/tmp/default-home").join(".claude")
495        );
496
497        crate::settings::update_settings(original_settings).unwrap();
498        set_test_home_override(None);
499    }
500
501    #[test]
502    fn get_claude_config_dir_env_overrides_settings() {
503        let _guard = lock_test_home_and_settings();
504        let original_settings = crate::settings::get_settings();
505        let mut settings = original_settings.clone();
506        settings.claude_config_dir = Some("/tmp/settings-override".to_string());
507        crate::settings::update_settings(settings).unwrap();
508        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
509        set_test_home_override(Some(Path::new("/tmp/home")));
510
511        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
512
513        crate::settings::update_settings(original_settings).unwrap();
514        set_test_home_override(None);
515    }
516
517    #[test]
518    fn get_claude_config_dir_blank_env_falls_back_to_settings() {
519        let _guard = lock_test_home_and_settings();
520        let original_settings = crate::settings::get_settings();
521        let mut settings = original_settings.clone();
522        settings.claude_config_dir = Some("/tmp/settings-override".to_string());
523        crate::settings::update_settings(settings).unwrap();
524        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
525        set_test_home_override(Some(Path::new("/tmp/home")));
526
527        assert_eq!(
528            get_claude_config_dir(),
529            PathBuf::from("/tmp/settings-override")
530        );
531
532        crate::settings::update_settings(original_settings).unwrap();
533        set_test_home_override(None);
534    }
535
536    // ──── migration tests ────
537
538    #[test]
539    fn migration_copies_config_json_and_db() {
540        let _guard = lock_test_home_and_settings();
541        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
542        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
543
544        let temp = tempfile::tempdir().expect("create temp dir");
545        let home = temp.path();
546        set_test_home_override(Some(home));
547
548        let old_dir = home.join(".cc-switch");
549        let new_dir = home.join(".cc-switch-tui");
550        let marker = new_dir.join(".migrated-from-cc-switch");
551
552        fs::create_dir_all(old_dir.join("skills")).unwrap();
553        fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
554        fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
555        fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
556
557        migrate_legacy_config_dir_if_needed();
558
559        assert!(
560            new_dir.join("config.json").exists(),
561            "config.json should be copied"
562        );
563        assert!(
564            new_dir.join("cc-switch.db").exists(),
565            "cc-switch.db should be copied"
566        );
567        assert!(
568            new_dir.join("skills").join("my-skill.md").exists(),
569            "skills/ should be recursively copied"
570        );
571        assert!(marker.exists(), "migration marker should be written");
572
573        set_test_home_override(None);
574    }
575
576    #[test]
577    fn migration_skips_when_target_has_marker() {
578        let _guard = lock_test_home_and_settings();
579        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
580        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
581
582        let temp = tempfile::tempdir().expect("create temp dir");
583        let home = temp.path();
584        set_test_home_override(Some(home));
585
586        let old_dir = home.join(".cc-switch");
587        let new_dir = home.join(".cc-switch-tui");
588        let marker = new_dir.join(".migrated-from-cc-switch");
589
590        fs::create_dir_all(&old_dir).unwrap();
591        fs::write(old_dir.join("config.json"), "v1").unwrap();
592        fs::create_dir_all(&new_dir).unwrap();
593        fs::write(&marker, "already migrated").unwrap();
594
595        migrate_legacy_config_dir_if_needed();
596
597        assert!(
598            !new_dir.join("config.json").exists(),
599            "should not copy when marker exists"
600        );
601
602        set_test_home_override(None);
603    }
604
605    #[test]
606    fn migration_skips_when_legacy_env_override_set() {
607        let _guard = lock_test_home_and_settings();
608        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
609        let _old =
610            ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
611
612        let temp = tempfile::tempdir().expect("create temp dir");
613        let home = temp.path();
614        set_test_home_override(Some(home));
615
616        let old_dir = home.join(".cc-switch");
617        let new_dir = home.join(".cc-switch-tui");
618
619        fs::create_dir_all(&old_dir).unwrap();
620        fs::write(old_dir.join("config.json"), "v1").unwrap();
621
622        migrate_legacy_config_dir_if_needed();
623
624        assert!(
625            !new_dir.exists(),
626            "should not create target dir when env override set"
627        );
628
629        set_test_home_override(None);
630    }
631
632    #[test]
633    fn migration_uses_new_env_override_as_target() {
634        let _guard = lock_test_home_and_settings();
635        let _tui =
636            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
637        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
638
639        let temp = tempfile::tempdir().expect("create temp dir");
640        let home = temp.path();
641        set_test_home_override(Some(home));
642
643        let old_dir = home.join(".cc-switch");
644        let new_dir = home.join(".config").join("cc-switch-tui");
645        let marker = new_dir.join(".migrated-from-cc-switch");
646
647        fs::create_dir_all(&old_dir).unwrap();
648        fs::write(old_dir.join("config.json"), "v1").unwrap();
649
650        assert_eq!(
651            legacy_config_migration_paths(),
652            Some((old_dir.clone(), new_dir.clone()))
653        );
654
655        migrate_legacy_config_dir_if_needed();
656
657        assert!(new_dir.join("config.json").exists());
658        assert!(marker.exists());
659
660        set_test_home_override(None);
661    }
662
663    #[test]
664    fn migration_skips_when_target_already_has_contents() {
665        let _guard = lock_test_home_and_settings();
666        let _tui =
667            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
668        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
669
670        let temp = tempfile::tempdir().expect("create temp dir");
671        let home = temp.path();
672        set_test_home_override(Some(home));
673
674        let old_dir = home.join(".cc-switch");
675        let new_dir = home.join(".config").join("cc-switch-tui");
676
677        fs::create_dir_all(&old_dir).unwrap();
678        fs::write(old_dir.join("config.json"), "legacy").unwrap();
679        fs::create_dir_all(&new_dir).unwrap();
680        fs::write(new_dir.join("config.json"), "current").unwrap();
681
682        assert_eq!(legacy_config_migration_paths(), None);
683
684        migrate_legacy_config_dir_if_needed();
685
686        assert_eq!(
687            fs::read_to_string(new_dir.join("config.json")).unwrap(),
688            "current"
689        );
690        assert!(!new_dir.join(".migrated-from-cc-switch").exists());
691
692        set_test_home_override(None);
693    }
694
695    #[test]
696    fn migration_copies_settings_json_when_target_only_has_db() {
697        let _guard = lock_test_home_and_settings();
698        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
699        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
700
701        let temp = tempfile::tempdir().expect("create temp dir");
702        let home = temp.path();
703        set_test_home_override(Some(home));
704
705        let old_dir = home.join(".cc-switch");
706        let new_dir = home.join(".cc-switch-tui");
707
708        fs::create_dir_all(&old_dir).unwrap();
709        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
710        fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
711        fs::create_dir_all(&new_dir).unwrap();
712        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
713
714        assert_eq!(
715            legacy_config_migration_paths(),
716            Some((old_dir.clone(), new_dir.clone()))
717        );
718
719        migrate_legacy_config_dir_if_needed();
720
721        assert_eq!(
722            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
723            "legacy-settings"
724        );
725        assert_eq!(
726            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
727            "current-db",
728            "existing target database must not be overwritten"
729        );
730        assert!(new_dir.join(".migrated-from-cc-switch").exists());
731
732        set_test_home_override(None);
733    }
734
735    #[test]
736    fn migration_repairs_missing_settings_json_after_success_marker() {
737        let _guard = lock_test_home_and_settings();
738        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
739        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
740
741        let temp = tempfile::tempdir().expect("create temp dir");
742        let home = temp.path();
743        set_test_home_override(Some(home));
744
745        let old_dir = home.join(".cc-switch");
746        let new_dir = home.join(".cc-switch-tui");
747        let marker = new_dir.join(".migrated-from-cc-switch");
748
749        fs::create_dir_all(&old_dir).unwrap();
750        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
751        fs::create_dir_all(&new_dir).unwrap();
752        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
753        fs::write(&marker, "Migrated from old path").unwrap();
754
755        assert_eq!(
756            legacy_config_migration_paths(),
757            None,
758            "already-migrated directories should not prompt again"
759        );
760
761        migrate_legacy_config_dir_if_needed();
762
763        assert_eq!(
764            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
765            "legacy-settings"
766        );
767        assert_eq!(
768            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
769            "current-db",
770            "repair must not overwrite the existing target database"
771        );
772
773        set_test_home_override(None);
774    }
775
776    #[test]
777    fn migration_is_idempotent() {
778        let _guard = lock_test_home_and_settings();
779        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
780        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
781
782        let temp = tempfile::tempdir().expect("create temp dir");
783        let home = temp.path();
784        set_test_home_override(Some(home));
785
786        let old_dir = home.join(".cc-switch");
787        let new_dir = home.join(".cc-switch-tui");
788        let marker = new_dir.join(".migrated-from-cc-switch");
789
790        fs::create_dir_all(&old_dir).unwrap();
791        fs::write(old_dir.join("config.json"), "v1").unwrap();
792
793        // First run
794        migrate_legacy_config_dir_if_needed();
795        assert!(new_dir.join("config.json").exists());
796        let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
797
798        // Second run — should be a no-op
799        migrate_legacy_config_dir_if_needed();
800        let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
801        assert_eq!(
802            mtime_after_first, mtime_after_second,
803            "second migration should not overwrite marker"
804        );
805
806        set_test_home_override(None);
807    }
808
809    #[test]
810    fn migration_preserves_old_directory() {
811        let _guard = lock_test_home_and_settings();
812        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
813        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
814
815        let temp = tempfile::tempdir().expect("create temp dir");
816        let home = temp.path();
817        set_test_home_override(Some(home));
818
819        let old_dir = home.join(".cc-switch");
820
821        fs::create_dir_all(&old_dir).unwrap();
822        fs::write(old_dir.join("config.json"), "v1").unwrap();
823
824        migrate_legacy_config_dir_if_needed();
825
826        assert!(old_dir.exists(), "old directory must be preserved");
827        assert!(
828            old_dir.join("config.json").exists(),
829            "old files must be preserved"
830        );
831
832        set_test_home_override(None);
833    }
834
835    #[test]
836    fn migration_copies_only_3_most_recent_backups() {
837        let _guard = lock_test_home_and_settings();
838        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
839        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
840
841        let temp = tempfile::tempdir().expect("create temp dir");
842        let home = temp.path();
843        set_test_home_override(Some(home));
844
845        let old_dir = home.join(".cc-switch");
846        let backup_dir = old_dir.join("backups");
847        fs::create_dir_all(&backup_dir).unwrap();
848
849        // Create 5 backup files with increasing mtime
850        for i in 1..=5 {
851            let path = backup_dir.join(format!("backup-{}.json", i));
852            fs::write(&path, format!("backup {}", i)).unwrap();
853            std::thread::sleep(std::time::Duration::from_millis(10));
854        }
855
856        migrate_legacy_config_dir_if_needed();
857
858        let new_backup_dir = home.join(".cc-switch-tui").join("backups");
859        let copied: Vec<_> = fs::read_dir(&new_backup_dir)
860            .unwrap()
861            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
862            .collect();
863
864        assert_eq!(
865            copied.len(),
866            3,
867            "only 3 most recent backups should be copied"
868        );
869        assert!(
870            copied.contains(&"backup-3.json".to_string()),
871            "third most recent should be copied"
872        );
873        assert!(
874            copied.contains(&"backup-4.json".to_string()),
875            "second most recent should be copied"
876        );
877        assert!(
878            copied.contains(&"backup-5.json".to_string()),
879            "most recent should be copied"
880        );
881
882        set_test_home_override(None);
883    }
884}
885
886/// 复制文件
887pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
888    fs::copy(from, to).map_err(|e| AppError::IoContext {
889        context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
890        source: e,
891    })?;
892    Ok(())
893}
894
895/// 删除文件
896pub fn delete_file(path: &Path) -> Result<(), AppError> {
897    if path.exists() {
898        fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
899    }
900    Ok(())
901}
902
903/// 递归复制目录内容(跳过软链接)
904fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
905    fs::create_dir_all(dst)?;
906    for entry in fs::read_dir(src)? {
907        let entry = entry?;
908        let file_type = entry.file_type()?;
909        if file_type.is_symlink() {
910            continue;
911        }
912        let src_path = entry.path();
913        let dst_path = dst.join(entry.file_name());
914        if file_type.is_dir() {
915            copy_dir_recursive(&src_path, &dst_path)?;
916        } else if !dst_path.exists() {
917            fs::copy(&src_path, &dst_path)?;
918        }
919    }
920    Ok(())
921}
922
923/// 复制备份目录中最近 3 个(按修改时间)条目
924fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
925    let mut entries: Vec<_> = fs::read_dir(src)?
926        .filter_map(|e| e.ok())
927        .filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
928        .collect();
929    entries.sort_by_key(|e| {
930        e.metadata()
931            .and_then(|m| m.modified())
932            .unwrap_or(std::time::UNIX_EPOCH)
933    });
934    entries.reverse();
935    entries.truncate(limit);
936
937    fs::create_dir_all(dst)?;
938    for entry in entries {
939        let src_path = entry.path();
940        let dst_path = dst.join(entry.file_name());
941        if entry.file_type().map_or(false, |t| t.is_dir()) {
942            copy_dir_recursive(&src_path, &dst_path)?;
943        } else if !dst_path.exists() {
944            fs::copy(&src_path, &dst_path)?;
945        }
946    }
947    Ok(())
948}
949
950fn target_allows_legacy_migration(new_dir: &Path) -> bool {
951    if !new_dir.exists() {
952        return true;
953    }
954    if !new_dir.is_dir() {
955        return false;
956    }
957
958    let entries = match fs::read_dir(new_dir) {
959        Ok(entries) => entries,
960        Err(_) => return false,
961    };
962
963    for entry in entries {
964        let Ok(entry) = entry else {
965            return false;
966        };
967        if entry.file_name() != "cc-switch.db" {
968            return false;
969        }
970    }
971    true
972}
973
974fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
975    ["settings.json", "config.json"]
976        .iter()
977        .any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
978}
979
980fn migration_marker_allows_repair(marker: &Path) -> bool {
981    match fs::read_to_string(marker) {
982        Ok(content) => content.starts_with("Migrated from "),
983        Err(_) => false,
984    }
985}
986
987/// 提取迁移前置检查逻辑,返回 (old_dir, new_dir, marker) 若条件满足,否则 None。
988fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
989    let home = home_dir()?;
990    let old_dir = home.join(".cc-switch");
991    let new_dir = effective_app_config_dir_without_migration(&home)?;
992    let marker = new_dir.join(".migrated-from-cc-switch");
993
994    if old_dir == new_dir {
995        return None;
996    }
997    if !old_dir.exists() || !old_dir.is_dir() {
998        return None;
999    }
1000    if marker.exists() {
1001        if !allow_repair
1002            || !migration_marker_allows_repair(&marker)
1003            || !needs_legacy_json_repair(&old_dir, &new_dir)
1004        {
1005            return None;
1006        }
1007    }
1008    let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
1009    if !has_contents {
1010        return None;
1011    }
1012    if !marker.exists() && !target_allows_legacy_migration(&new_dir) {
1013        return None;
1014    }
1015
1016    Some((old_dir, new_dir, marker))
1017}
1018
1019/// 返回待迁移的旧配置目录和当前配置目录。
1020pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1021    migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1022}
1023
1024/// 检查是否存在尚未迁移的旧版配置目录。
1025///
1026/// 返回 true 表示 ~/.cc-switch/ 存在且未迁移,应提示用户确认。
1027pub fn check_legacy_config_dir_migration_needed() -> bool {
1028    legacy_config_migration_paths().is_some()
1029}
1030
1031/// 用户拒绝迁移:写入标记文件以永不再次提示。
1032///
1033/// 错误仅记录到 stderr,绝不阻塞启动。
1034pub fn skip_legacy_config_dir_migration() {
1035    let (_, new_dir, marker) = match migration_guard(false) {
1036        Some(v) => v,
1037        None => return,
1038    };
1039
1040    if let Err(e) = std::fs::create_dir_all(&new_dir)
1041        .and_then(|_| std::fs::write(&marker, "User declined migration"))
1042    {
1043        eprintln!(
1044            "cc-switch: failed to write skip-migration marker at {}: {e}",
1045            marker.display()
1046        );
1047    }
1048}
1049
1050/// 首次运行时自动将旧版 ~/.cc-switch/ 迁移到 ~/.cc-switch-tui/
1051///
1052/// 仅在以下条件全部满足时执行:
1053/// - 未设置 CC_SWITCH_TUI_CONFIG_DIR 或 CC_SWITCH_CONFIG_DIR 环境变量
1054/// - 旧目录 ~/.cc-switch/ 存在且非空
1055/// - 目标目录不存在 .migrated-from-cc-switch 标记文件
1056///
1057/// 非破坏性:旧目录完好保留。错误仅记录警告,绝不阻塞启动。
1058pub fn migrate_legacy_config_dir_if_needed() {
1059    let (old_dir, new_dir, marker) = match migration_guard(true) {
1060        Some(v) => v,
1061        None => return,
1062    };
1063
1064    // Perform migration (errors caught, never propagate)
1065    if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
1066        eprintln!(
1067            "cc-switch: legacy config migration failed: {e} (old data preserved at {})",
1068            old_dir.display()
1069        );
1070    }
1071}
1072
1073fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
1074    fs::create_dir_all(new_dir)?;
1075
1076    for entry in fs::read_dir(old_dir)? {
1077        let entry = entry?;
1078        let file_type = entry.file_type()?;
1079        if file_type.is_symlink() {
1080            continue;
1081        }
1082        let src_path = entry.path();
1083        let file_name = entry.file_name();
1084        let dst_path = new_dir.join(&file_name);
1085
1086        if file_name == "backups" && file_type.is_dir() {
1087            copy_recent_backups(&src_path, &dst_path, 3)?;
1088        } else if file_type.is_dir() {
1089            copy_dir_recursive(&src_path, &dst_path)?;
1090        } else if !dst_path.exists() {
1091            fs::copy(&src_path, &dst_path)?;
1092        }
1093    }
1094
1095    // Write marker file to prevent re-migration
1096    fs::write(
1097        marker,
1098        format!(
1099            "Migrated from {} on {}",
1100            old_dir.display(),
1101            chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1102        ),
1103    )?;
1104
1105    eprintln!(
1106        "cc-switch: config migrated from {} to {} (old directory preserved)",
1107        old_dir.display(),
1108        new_dir.display()
1109    );
1110    Ok(())
1111}
1112
1113/// 检查 Claude Code 配置状态
1114#[derive(Serialize, Deserialize)]
1115pub struct ConfigStatus {
1116    pub exists: bool,
1117    pub path: String,
1118}
1119
1120/// 获取 Claude Code 配置状态
1121pub fn get_claude_config_status() -> ConfigStatus {
1122    let path = get_claude_settings_path();
1123    ConfigStatus {
1124        exists: path.exists(),
1125        path: path.to_string_lossy().to_string(),
1126    }
1127}