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