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 expand_tilde(path: PathBuf) -> PathBuf {
21 let lossy = path.to_string_lossy();
22 if lossy == "~" {
24 return home_dir().unwrap_or(path);
25 }
26 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
37pub 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
54pub 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
72pub 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
82pub 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 let legacy = dir.join("claude.json");
91 if legacy.exists() {
92 return legacy;
93 }
94 settings
96}
97
98pub fn get_app_config_dir() -> PathBuf {
102 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 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 let path = home_dir()
128 .expect("无法获取用户主目录")
129 .join(".cc-switch-tui");
130
131 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
143pub fn get_app_config_path() -> PathBuf {
145 get_app_config_dir().join("config.json")
146}
147
148pub 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
159pub 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
168pub 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
179pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
181 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
192pub 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
200pub 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 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 #[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 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 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 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
689pub 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
698pub 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
706fn 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
726fn 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
753fn 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
780pub fn check_legacy_config_dir_migration_needed() -> bool {
784 migration_guard().is_some()
785}
786
787pub 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
806pub 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 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 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#[derive(Serialize, Deserialize)]
871pub struct ConfigStatus {
872 pub exists: bool,
873 pub path: String,
874}
875
876pub 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}