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    struct SettingsGuard {
314        original: crate::settings::AppSettings,
315    }
316
317    impl SettingsGuard {
318        fn with_claude_config_dir(dir: Option<&str>) -> Self {
319            let original = crate::settings::get_settings();
320            let mut settings = original.clone();
321            settings.claude_config_dir = dir.map(str::to_string);
322            crate::settings::update_settings(settings).unwrap();
323            Self { original }
324        }
325    }
326
327    impl Drop for SettingsGuard {
328        fn drop(&mut self) {
329            let _ = crate::settings::update_settings(self.original.clone());
330        }
331    }
332
333    #[test]
334    fn derive_mcp_path_from_override_preserves_folder_name() {
335        let override_dir = PathBuf::from("/tmp/profile/.claude");
336        let derived = derive_mcp_path_from_override(&override_dir)
337            .expect("should derive path for nested dir");
338        assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
339    }
340
341    #[test]
342    fn derive_mcp_path_from_override_handles_non_hidden_folder() {
343        let override_dir = PathBuf::from("/data/claude-config");
344        let derived = derive_mcp_path_from_override(&override_dir)
345            .expect("should derive path for standard dir");
346        assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
347    }
348
349    #[test]
350    fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
351        let override_dir = PathBuf::from("claude");
352        let derived = derive_mcp_path_from_override(&override_dir)
353            .expect("should derive path for single segment");
354        assert_eq!(derived, PathBuf::from("claude.json"));
355    }
356
357    #[test]
358    fn derive_mcp_path_from_root_like_dir_returns_none() {
359        let override_dir = PathBuf::from("/");
360        assert!(derive_mcp_path_from_override(&override_dir).is_none());
361    }
362
363    #[test]
364    fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
365        let _guard = lock_test_home_and_settings();
366        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
367        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
368        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
369
370        assert_eq!(
371            get_app_config_dir(),
372            PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
373        );
374
375        set_test_home_override(None);
376    }
377
378    #[test]
379    fn get_app_config_dir_uses_env_override_when_set() {
380        let _guard = lock_test_home_and_settings();
381        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
382        let _old = ConfigDirEnvGuard::new(
383            "CC_SWITCH_CONFIG_DIR",
384            Some("/tmp/cc-switch-config-override"),
385        );
386        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
387
388        assert_eq!(
389            get_app_config_dir(),
390            PathBuf::from("/tmp/cc-switch-config-override")
391        );
392
393        set_test_home_override(None);
394    }
395
396    #[test]
397    fn get_app_config_dir_ignores_blank_env_override() {
398        let _guard = lock_test_home_and_settings();
399        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
400        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("   "));
401        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
402
403        assert_eq!(
404            get_app_config_dir(),
405            PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
406        );
407
408        set_test_home_override(None);
409    }
410
411    #[test]
412    fn get_app_config_dir_prefers_new_env_var() {
413        let _guard = lock_test_home_and_settings();
414        let _new =
415            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
416        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
417        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
418
419        assert_eq!(
420            get_app_config_dir(),
421            PathBuf::from("/tmp/cc-switch-tui-new")
422        );
423
424        set_test_home_override(None);
425    }
426
427    #[test]
428    fn get_app_config_dir_new_env_var_alone_works() {
429        let _guard = lock_test_home_and_settings();
430        let _new =
431            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
432        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
433        set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
434
435        assert_eq!(
436            get_app_config_dir(),
437            PathBuf::from("/tmp/cc-switch-tui-alone")
438        );
439
440        set_test_home_override(None);
441    }
442
443    #[test]
444    fn get_app_config_dir_expands_tilde_in_new_env_var() {
445        let _guard = lock_test_home_and_settings();
446        let _new =
447            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
448        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
449        set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
450
451        assert_eq!(
452            get_app_config_dir(),
453            PathBuf::from("/tmp/cc-switch-home-tilde")
454                .join(".config")
455                .join("cc-switch-tui")
456        );
457
458        set_test_home_override(None);
459    }
460
461    #[test]
462    fn get_claude_config_dir_expands_tilde_in_env_var() {
463        let _guard = lock_test_home_and_settings();
464        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
465        set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
466
467        assert_eq!(
468            get_claude_config_dir(),
469            PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
470        );
471
472        set_test_home_override(None);
473    }
474
475    #[test]
476    fn get_claude_config_dir_respects_env_var() {
477        let _guard = lock_test_home_and_settings();
478        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
479        set_test_home_override(Some(Path::new("/tmp/claude-home")));
480
481        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
482
483        set_test_home_override(None);
484    }
485
486    #[test]
487    fn get_claude_config_dir_ignores_blank_env_var() {
488        let _guard = lock_test_home_and_settings();
489        let _settings = SettingsGuard::with_claude_config_dir(None);
490        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
491        set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
492
493        assert_eq!(
494            get_claude_config_dir(),
495            PathBuf::from("/tmp/claude-home-blank").join(".claude")
496        );
497
498        set_test_home_override(None);
499    }
500
501    #[test]
502    fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
503        let _guard = lock_test_home_and_settings();
504        let _settings = SettingsGuard::with_claude_config_dir(None);
505        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
506        set_test_home_override(Some(Path::new("/tmp/default-home")));
507
508        assert_eq!(
509            get_claude_config_dir(),
510            PathBuf::from("/tmp/default-home").join(".claude")
511        );
512
513        set_test_home_override(None);
514    }
515
516    #[test]
517    fn get_claude_config_dir_env_overrides_settings() {
518        let _guard = lock_test_home_and_settings();
519        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
520        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
521        set_test_home_override(Some(Path::new("/tmp/home")));
522
523        assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
524
525        set_test_home_override(None);
526    }
527
528    #[test]
529    fn get_claude_config_dir_blank_env_falls_back_to_settings() {
530        let _guard = lock_test_home_and_settings();
531        let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
532        let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("   "));
533        set_test_home_override(Some(Path::new("/tmp/home")));
534
535        assert_eq!(
536            get_claude_config_dir(),
537            PathBuf::from("/tmp/settings-override")
538        );
539
540        set_test_home_override(None);
541    }
542
543    // ──── migration tests ────
544
545    #[test]
546    fn migration_copies_config_json_and_db() {
547        let _guard = lock_test_home_and_settings();
548        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
549        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
550
551        let temp = tempfile::tempdir().expect("create temp dir");
552        let home = temp.path();
553        set_test_home_override(Some(home));
554
555        let old_dir = home.join(".cc-switch");
556        let new_dir = home.join(".cc-switch-tui");
557        let marker = new_dir.join(".migrated-from-cc-switch");
558
559        fs::create_dir_all(old_dir.join("skills")).unwrap();
560        fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
561        fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
562        fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
563
564        migrate_legacy_config_dir_if_needed();
565
566        assert!(
567            new_dir.join("config.json").exists(),
568            "config.json should be copied"
569        );
570        assert!(
571            new_dir.join("cc-switch.db").exists(),
572            "cc-switch.db should be copied"
573        );
574        assert!(
575            new_dir.join("skills").join("my-skill.md").exists(),
576            "skills/ should be recursively copied"
577        );
578        assert!(marker.exists(), "migration marker should be written");
579
580        set_test_home_override(None);
581    }
582
583    #[test]
584    fn migration_skips_when_target_has_marker() {
585        let _guard = lock_test_home_and_settings();
586        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
587        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
588
589        let temp = tempfile::tempdir().expect("create temp dir");
590        let home = temp.path();
591        set_test_home_override(Some(home));
592
593        let old_dir = home.join(".cc-switch");
594        let new_dir = home.join(".cc-switch-tui");
595        let marker = new_dir.join(".migrated-from-cc-switch");
596
597        fs::create_dir_all(&old_dir).unwrap();
598        fs::write(old_dir.join("config.json"), "v1").unwrap();
599        fs::create_dir_all(&new_dir).unwrap();
600        fs::write(&marker, "already migrated").unwrap();
601
602        migrate_legacy_config_dir_if_needed();
603
604        assert!(
605            !new_dir.join("config.json").exists(),
606            "should not copy when marker exists"
607        );
608
609        set_test_home_override(None);
610    }
611
612    #[test]
613    fn migration_skips_when_legacy_env_override_set() {
614        let _guard = lock_test_home_and_settings();
615        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
616        let _old =
617            ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
618
619        let temp = tempfile::tempdir().expect("create temp dir");
620        let home = temp.path();
621        set_test_home_override(Some(home));
622
623        let old_dir = home.join(".cc-switch");
624        let new_dir = home.join(".cc-switch-tui");
625
626        fs::create_dir_all(&old_dir).unwrap();
627        fs::write(old_dir.join("config.json"), "v1").unwrap();
628
629        migrate_legacy_config_dir_if_needed();
630
631        assert!(
632            !new_dir.exists(),
633            "should not create target dir when env override set"
634        );
635
636        set_test_home_override(None);
637    }
638
639    #[test]
640    fn migration_uses_new_env_override_as_target() {
641        let _guard = lock_test_home_and_settings();
642        let _tui =
643            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
644        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
645
646        let temp = tempfile::tempdir().expect("create temp dir");
647        let home = temp.path();
648        set_test_home_override(Some(home));
649
650        let old_dir = home.join(".cc-switch");
651        let new_dir = home.join(".config").join("cc-switch-tui");
652        let marker = new_dir.join(".migrated-from-cc-switch");
653
654        fs::create_dir_all(&old_dir).unwrap();
655        fs::write(old_dir.join("config.json"), "v1").unwrap();
656
657        assert_eq!(
658            legacy_config_migration_paths(),
659            Some((old_dir.clone(), new_dir.clone()))
660        );
661
662        migrate_legacy_config_dir_if_needed();
663
664        assert!(new_dir.join("config.json").exists());
665        assert!(marker.exists());
666
667        set_test_home_override(None);
668    }
669
670    #[test]
671    fn migration_skips_when_target_already_has_contents() {
672        let _guard = lock_test_home_and_settings();
673        let _tui =
674            ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
675        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
676
677        let temp = tempfile::tempdir().expect("create temp dir");
678        let home = temp.path();
679        set_test_home_override(Some(home));
680
681        let old_dir = home.join(".cc-switch");
682        let new_dir = home.join(".config").join("cc-switch-tui");
683
684        fs::create_dir_all(&old_dir).unwrap();
685        fs::write(old_dir.join("config.json"), "legacy").unwrap();
686        fs::create_dir_all(&new_dir).unwrap();
687        fs::write(new_dir.join("config.json"), "current").unwrap();
688
689        assert_eq!(legacy_config_migration_paths(), None);
690
691        migrate_legacy_config_dir_if_needed();
692
693        assert_eq!(
694            fs::read_to_string(new_dir.join("config.json")).unwrap(),
695            "current"
696        );
697        assert!(!new_dir.join(".migrated-from-cc-switch").exists());
698
699        set_test_home_override(None);
700    }
701
702    #[test]
703    fn migration_copies_settings_json_when_target_only_has_db() {
704        let _guard = lock_test_home_and_settings();
705        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
706        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
707
708        let temp = tempfile::tempdir().expect("create temp dir");
709        let home = temp.path();
710        set_test_home_override(Some(home));
711
712        let old_dir = home.join(".cc-switch");
713        let new_dir = home.join(".cc-switch-tui");
714
715        fs::create_dir_all(&old_dir).unwrap();
716        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
717        fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
718        fs::create_dir_all(&new_dir).unwrap();
719        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
720
721        assert_eq!(
722            legacy_config_migration_paths(),
723            Some((old_dir.clone(), new_dir.clone()))
724        );
725
726        migrate_legacy_config_dir_if_needed();
727
728        assert_eq!(
729            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
730            "legacy-settings"
731        );
732        assert_eq!(
733            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
734            "current-db",
735            "existing target database must not be overwritten"
736        );
737        assert!(new_dir.join(".migrated-from-cc-switch").exists());
738
739        set_test_home_override(None);
740    }
741
742    #[test]
743    fn migration_repairs_missing_settings_json_after_success_marker() {
744        let _guard = lock_test_home_and_settings();
745        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
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(".cc-switch-tui");
754        let marker = new_dir.join(".migrated-from-cc-switch");
755
756        fs::create_dir_all(&old_dir).unwrap();
757        fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
758        fs::create_dir_all(&new_dir).unwrap();
759        fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
760        fs::write(&marker, "Migrated from old path").unwrap();
761
762        assert_eq!(
763            legacy_config_migration_paths(),
764            None,
765            "already-migrated directories should not prompt again"
766        );
767
768        migrate_legacy_config_dir_if_needed();
769
770        assert_eq!(
771            fs::read_to_string(new_dir.join("settings.json")).unwrap(),
772            "legacy-settings"
773        );
774        assert_eq!(
775            fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
776            "current-db",
777            "repair must not overwrite the existing target database"
778        );
779
780        set_test_home_override(None);
781    }
782
783    #[test]
784    fn migration_is_idempotent() {
785        let _guard = lock_test_home_and_settings();
786        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
787        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
788
789        let temp = tempfile::tempdir().expect("create temp dir");
790        let home = temp.path();
791        set_test_home_override(Some(home));
792
793        let old_dir = home.join(".cc-switch");
794        let new_dir = home.join(".cc-switch-tui");
795        let marker = new_dir.join(".migrated-from-cc-switch");
796
797        fs::create_dir_all(&old_dir).unwrap();
798        fs::write(old_dir.join("config.json"), "v1").unwrap();
799
800        // First run
801        migrate_legacy_config_dir_if_needed();
802        assert!(new_dir.join("config.json").exists());
803        let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
804
805        // Second run — should be a no-op
806        migrate_legacy_config_dir_if_needed();
807        let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
808        assert_eq!(
809            mtime_after_first, mtime_after_second,
810            "second migration should not overwrite marker"
811        );
812
813        set_test_home_override(None);
814    }
815
816    #[test]
817    fn migration_preserves_old_directory() {
818        let _guard = lock_test_home_and_settings();
819        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
820        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
821
822        let temp = tempfile::tempdir().expect("create temp dir");
823        let home = temp.path();
824        set_test_home_override(Some(home));
825
826        let old_dir = home.join(".cc-switch");
827
828        fs::create_dir_all(&old_dir).unwrap();
829        fs::write(old_dir.join("config.json"), "v1").unwrap();
830
831        migrate_legacy_config_dir_if_needed();
832
833        assert!(old_dir.exists(), "old directory must be preserved");
834        assert!(
835            old_dir.join("config.json").exists(),
836            "old files must be preserved"
837        );
838
839        set_test_home_override(None);
840    }
841
842    #[test]
843    fn migration_copies_only_3_most_recent_backups() {
844        let _guard = lock_test_home_and_settings();
845        let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
846        let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
847
848        let temp = tempfile::tempdir().expect("create temp dir");
849        let home = temp.path();
850        set_test_home_override(Some(home));
851
852        let old_dir = home.join(".cc-switch");
853        let backup_dir = old_dir.join("backups");
854        fs::create_dir_all(&backup_dir).unwrap();
855
856        // Create 5 backup files with increasing mtime
857        for i in 1..=5 {
858            let path = backup_dir.join(format!("backup-{}.json", i));
859            fs::write(&path, format!("backup {}", i)).unwrap();
860            std::thread::sleep(std::time::Duration::from_millis(10));
861        }
862
863        migrate_legacy_config_dir_if_needed();
864
865        let new_backup_dir = home.join(".cc-switch-tui").join("backups");
866        let copied: Vec<_> = fs::read_dir(&new_backup_dir)
867            .unwrap()
868            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
869            .collect();
870
871        assert_eq!(
872            copied.len(),
873            3,
874            "only 3 most recent backups should be copied"
875        );
876        assert!(
877            copied.contains(&"backup-3.json".to_string()),
878            "third most recent should be copied"
879        );
880        assert!(
881            copied.contains(&"backup-4.json".to_string()),
882            "second most recent should be copied"
883        );
884        assert!(
885            copied.contains(&"backup-5.json".to_string()),
886            "most recent should be copied"
887        );
888
889        set_test_home_override(None);
890    }
891}
892
893/// 复制文件
894pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
895    fs::copy(from, to).map_err(|e| AppError::IoContext {
896        context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
897        source: e,
898    })?;
899    Ok(())
900}
901
902/// 删除文件
903pub fn delete_file(path: &Path) -> Result<(), AppError> {
904    if path.exists() {
905        fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
906    }
907    Ok(())
908}
909
910/// 递归复制目录内容(跳过软链接)
911fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
912    fs::create_dir_all(dst)?;
913    for entry in fs::read_dir(src)? {
914        let entry = entry?;
915        let file_type = entry.file_type()?;
916        if file_type.is_symlink() {
917            continue;
918        }
919        let src_path = entry.path();
920        let dst_path = dst.join(entry.file_name());
921        if file_type.is_dir() {
922            copy_dir_recursive(&src_path, &dst_path)?;
923        } else if !dst_path.exists() {
924            fs::copy(&src_path, &dst_path)?;
925        }
926    }
927    Ok(())
928}
929
930/// 复制备份目录中最近 3 个(按修改时间)条目
931fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
932    let mut entries: Vec<_> = fs::read_dir(src)?
933        .filter_map(|e| e.ok())
934        .filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
935        .collect();
936    entries.sort_by_key(|e| {
937        e.metadata()
938            .and_then(|m| m.modified())
939            .unwrap_or(std::time::UNIX_EPOCH)
940    });
941    entries.reverse();
942    entries.truncate(limit);
943
944    fs::create_dir_all(dst)?;
945    for entry in entries {
946        let src_path = entry.path();
947        let dst_path = dst.join(entry.file_name());
948        if entry.file_type().map_or(false, |t| t.is_dir()) {
949            copy_dir_recursive(&src_path, &dst_path)?;
950        } else if !dst_path.exists() {
951            fs::copy(&src_path, &dst_path)?;
952        }
953    }
954    Ok(())
955}
956
957fn target_allows_legacy_migration(new_dir: &Path) -> bool {
958    if !new_dir.exists() {
959        return true;
960    }
961    if !new_dir.is_dir() {
962        return false;
963    }
964
965    let entries = match fs::read_dir(new_dir) {
966        Ok(entries) => entries,
967        Err(_) => return false,
968    };
969
970    for entry in entries {
971        let Ok(entry) = entry else {
972            return false;
973        };
974        if entry.file_name() != "cc-switch.db" {
975            return false;
976        }
977    }
978    true
979}
980
981fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
982    ["settings.json", "config.json"]
983        .iter()
984        .any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
985}
986
987fn migration_marker_allows_repair(marker: &Path) -> bool {
988    match fs::read_to_string(marker) {
989        Ok(content) => content.starts_with("Migrated from "),
990        Err(_) => false,
991    }
992}
993
994/// 提取迁移前置检查逻辑,返回 (old_dir, new_dir, marker) 若条件满足,否则 None。
995fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
996    let home = home_dir()?;
997    let old_dir = home.join(".cc-switch");
998    let new_dir = effective_app_config_dir_without_migration(&home)?;
999    let marker = new_dir.join(".migrated-from-cc-switch");
1000
1001    if old_dir == new_dir {
1002        return None;
1003    }
1004    if !old_dir.exists() || !old_dir.is_dir() {
1005        return None;
1006    }
1007    if marker.exists() {
1008        if !allow_repair
1009            || !migration_marker_allows_repair(&marker)
1010            || !needs_legacy_json_repair(&old_dir, &new_dir)
1011        {
1012            return None;
1013        }
1014    }
1015    let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
1016    if !has_contents {
1017        return None;
1018    }
1019    if !marker.exists() && !target_allows_legacy_migration(&new_dir) {
1020        return None;
1021    }
1022
1023    Some((old_dir, new_dir, marker))
1024}
1025
1026/// 返回待迁移的旧配置目录和当前配置目录。
1027pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1028    migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1029}
1030
1031/// 检查是否存在尚未迁移的旧版配置目录。
1032///
1033/// 返回 true 表示 ~/.cc-switch/ 存在且未迁移,应提示用户确认。
1034pub fn check_legacy_config_dir_migration_needed() -> bool {
1035    legacy_config_migration_paths().is_some()
1036}
1037
1038/// 用户拒绝迁移:写入标记文件以永不再次提示。
1039///
1040/// 错误仅记录到 stderr,绝不阻塞启动。
1041pub fn skip_legacy_config_dir_migration() {
1042    let (_, new_dir, marker) = match migration_guard(false) {
1043        Some(v) => v,
1044        None => return,
1045    };
1046
1047    if let Err(e) = std::fs::create_dir_all(&new_dir)
1048        .and_then(|_| std::fs::write(&marker, "User declined migration"))
1049    {
1050        eprintln!(
1051            "cc-switch: failed to write skip-migration marker at {}: {e}",
1052            marker.display()
1053        );
1054    }
1055}
1056
1057/// 首次运行时自动将旧版 ~/.cc-switch/ 迁移到 ~/.cc-switch-tui/
1058///
1059/// 仅在以下条件全部满足时执行:
1060/// - 未设置 CC_SWITCH_TUI_CONFIG_DIR 或 CC_SWITCH_CONFIG_DIR 环境变量
1061/// - 旧目录 ~/.cc-switch/ 存在且非空
1062/// - 目标目录不存在 .migrated-from-cc-switch 标记文件
1063///
1064/// 非破坏性:旧目录完好保留。错误仅记录警告,绝不阻塞启动。
1065pub fn migrate_legacy_config_dir_if_needed() {
1066    let (old_dir, new_dir, marker) = match migration_guard(true) {
1067        Some(v) => v,
1068        None => return,
1069    };
1070
1071    // Perform migration (errors caught, never propagate)
1072    if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
1073        eprintln!(
1074            "cc-switch: legacy config migration failed: {e} (old data preserved at {})",
1075            old_dir.display()
1076        );
1077    }
1078}
1079
1080fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
1081    fs::create_dir_all(new_dir)?;
1082
1083    for entry in fs::read_dir(old_dir)? {
1084        let entry = entry?;
1085        let file_type = entry.file_type()?;
1086        if file_type.is_symlink() {
1087            continue;
1088        }
1089        let src_path = entry.path();
1090        let file_name = entry.file_name();
1091        let dst_path = new_dir.join(&file_name);
1092
1093        if file_name == "backups" && file_type.is_dir() {
1094            copy_recent_backups(&src_path, &dst_path, 3)?;
1095        } else if file_type.is_dir() {
1096            copy_dir_recursive(&src_path, &dst_path)?;
1097        } else if !dst_path.exists() {
1098            fs::copy(&src_path, &dst_path)?;
1099        }
1100    }
1101
1102    // Write marker file to prevent re-migration
1103    fs::write(
1104        marker,
1105        format!(
1106            "Migrated from {} on {}",
1107            old_dir.display(),
1108            chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1109        ),
1110    )?;
1111
1112    eprintln!(
1113        "cc-switch: config migrated from {} to {} (old directory preserved)",
1114        old_dir.display(),
1115        new_dir.display()
1116    );
1117    Ok(())
1118}
1119
1120/// 检查 Claude Code 配置状态
1121#[derive(Serialize, Deserialize)]
1122pub struct ConfigStatus {
1123    pub exists: bool,
1124    pub path: String,
1125}
1126
1127/// 获取 Claude Code 配置状态
1128pub fn get_claude_config_status() -> ConfigStatus {
1129    let path = get_claude_settings_path();
1130    ConfigStatus {
1131        exists: path.exists(),
1132        path: path.to_string_lossy().to_string(),
1133    }
1134}