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 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
27fn 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
47pub 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
64pub 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
97pub 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
107pub 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 let legacy = dir.join("claude.json");
116 if legacy.exists() {
117 return legacy;
118 }
119 settings
121}
122
123pub fn get_app_config_dir() -> PathBuf {
127 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 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 let path = home_dir()
154 .expect("无法获取用户主目录")
155 .join(".cc-switch-tui");
156
157 migrate_legacy_config_dir_once();
160
161 path
162}
163
164pub fn get_app_config_path() -> PathBuf {
166 get_app_config_dir().join("config.json")
167}
168
169pub 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
180pub 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
189pub 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
200pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
202 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
213pub 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
221pub 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 if path.exists() {
261 let _ = fs::remove_file(path);
262 }
263 fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
264 context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
265 source: e,
266 })?;
267 }
268
269 #[cfg(not(windows))]
270 {
271 fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
272 context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
273 source: e,
274 })?;
275 }
276 Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
283 use std::ffi::OsString;
284
285 struct ConfigDirEnvGuard {
286 key: String,
287 original: Option<OsString>,
288 }
289
290 impl ConfigDirEnvGuard {
291 fn new(key: &str, value: Option<&str>) -> Self {
292 let original = env::var_os(key);
293 match value {
294 Some(v) => unsafe { env::set_var(key, v) },
295 None => unsafe { env::remove_var(key) },
296 }
297 Self {
298 key: key.to_string(),
299 original,
300 }
301 }
302 }
303
304 impl Drop for ConfigDirEnvGuard {
305 fn drop(&mut self) {
306 match self.original.as_ref() {
307 Some(value) => unsafe { env::set_var(&self.key, value) },
308 None => unsafe { env::remove_var(&self.key) },
309 }
310 }
311 }
312
313 #[test]
314 fn derive_mcp_path_from_override_preserves_folder_name() {
315 let override_dir = PathBuf::from("/tmp/profile/.claude");
316 let derived = derive_mcp_path_from_override(&override_dir)
317 .expect("should derive path for nested dir");
318 assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
319 }
320
321 #[test]
322 fn derive_mcp_path_from_override_handles_non_hidden_folder() {
323 let override_dir = PathBuf::from("/data/claude-config");
324 let derived = derive_mcp_path_from_override(&override_dir)
325 .expect("should derive path for standard dir");
326 assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
327 }
328
329 #[test]
330 fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
331 let override_dir = PathBuf::from("claude");
332 let derived = derive_mcp_path_from_override(&override_dir)
333 .expect("should derive path for single segment");
334 assert_eq!(derived, PathBuf::from("claude.json"));
335 }
336
337 #[test]
338 fn derive_mcp_path_from_root_like_dir_returns_none() {
339 let override_dir = PathBuf::from("/");
340 assert!(derive_mcp_path_from_override(&override_dir).is_none());
341 }
342
343 #[test]
344 fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
345 let _guard = lock_test_home_and_settings();
346 let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
347 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
348
349 assert_eq!(
350 get_app_config_dir(),
351 PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
352 );
353
354 set_test_home_override(None);
355 }
356
357 #[test]
358 fn get_app_config_dir_uses_env_override_when_set() {
359 let _guard = lock_test_home_and_settings();
360 let _env = ConfigDirEnvGuard::new(
361 "CC_SWITCH_CONFIG_DIR",
362 Some("/tmp/cc-switch-config-override"),
363 );
364 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
365
366 assert_eq!(
367 get_app_config_dir(),
368 PathBuf::from("/tmp/cc-switch-config-override")
369 );
370
371 set_test_home_override(None);
372 }
373
374 #[test]
375 fn get_app_config_dir_ignores_blank_env_override() {
376 let _guard = lock_test_home_and_settings();
377 let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(" "));
378 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
379
380 assert_eq!(
381 get_app_config_dir(),
382 PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
383 );
384
385 set_test_home_override(None);
386 }
387
388 #[test]
389 fn get_app_config_dir_prefers_new_env_var() {
390 let _guard = lock_test_home_and_settings();
391 let _new =
392 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
393 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
394 set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
395
396 assert_eq!(
397 get_app_config_dir(),
398 PathBuf::from("/tmp/cc-switch-tui-new")
399 );
400
401 set_test_home_override(None);
402 }
403
404 #[test]
405 fn get_app_config_dir_new_env_var_alone_works() {
406 let _guard = lock_test_home_and_settings();
407 let _new =
408 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
409 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
410 set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
411
412 assert_eq!(
413 get_app_config_dir(),
414 PathBuf::from("/tmp/cc-switch-tui-alone")
415 );
416
417 set_test_home_override(None);
418 }
419
420 #[test]
421 fn get_app_config_dir_expands_tilde_in_new_env_var() {
422 let _guard = lock_test_home_and_settings();
423 let _new =
424 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
425 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
426 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
427
428 assert_eq!(
429 get_app_config_dir(),
430 PathBuf::from("/tmp/cc-switch-home-tilde")
431 .join(".config")
432 .join("cc-switch-tui")
433 );
434
435 set_test_home_override(None);
436 }
437
438 #[test]
439 fn get_claude_config_dir_expands_tilde_in_env_var() {
440 let _guard = lock_test_home_and_settings();
441 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
442 set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
443
444 assert_eq!(
445 get_claude_config_dir(),
446 PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
447 );
448
449 set_test_home_override(None);
450 }
451
452 #[test]
453 fn get_claude_config_dir_respects_env_var() {
454 let _guard = lock_test_home_and_settings();
455 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
456 set_test_home_override(Some(Path::new("/tmp/claude-home")));
457
458 assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
459
460 set_test_home_override(None);
461 }
462
463 #[test]
464 fn get_claude_config_dir_ignores_blank_env_var() {
465 let _guard = lock_test_home_and_settings();
466 let original_settings = crate::settings::get_settings();
467 let mut settings = original_settings.clone();
468 settings.claude_config_dir = None;
469 crate::settings::update_settings(settings).unwrap();
470 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
471 set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
472
473 assert_eq!(
474 get_claude_config_dir(),
475 PathBuf::from("/tmp/claude-home-blank").join(".claude")
476 );
477
478 crate::settings::update_settings(original_settings).unwrap();
479 set_test_home_override(None);
480 }
481
482 #[test]
483 fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
484 let _guard = lock_test_home_and_settings();
485 let original_settings = crate::settings::get_settings();
486 let mut settings = original_settings.clone();
487 settings.claude_config_dir = None;
488 crate::settings::update_settings(settings).unwrap();
489 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
490 set_test_home_override(Some(Path::new("/tmp/default-home")));
491
492 assert_eq!(
493 get_claude_config_dir(),
494 PathBuf::from("/tmp/default-home").join(".claude")
495 );
496
497 crate::settings::update_settings(original_settings).unwrap();
498 set_test_home_override(None);
499 }
500
501 #[test]
502 fn get_claude_config_dir_env_overrides_settings() {
503 let _guard = lock_test_home_and_settings();
504 let original_settings = crate::settings::get_settings();
505 let mut settings = original_settings.clone();
506 settings.claude_config_dir = Some("/tmp/settings-override".to_string());
507 crate::settings::update_settings(settings).unwrap();
508 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
509 set_test_home_override(Some(Path::new("/tmp/home")));
510
511 assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
512
513 crate::settings::update_settings(original_settings).unwrap();
514 set_test_home_override(None);
515 }
516
517 #[test]
518 fn get_claude_config_dir_blank_env_falls_back_to_settings() {
519 let _guard = lock_test_home_and_settings();
520 let original_settings = crate::settings::get_settings();
521 let mut settings = original_settings.clone();
522 settings.claude_config_dir = Some("/tmp/settings-override".to_string());
523 crate::settings::update_settings(settings).unwrap();
524 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
525 set_test_home_override(Some(Path::new("/tmp/home")));
526
527 assert_eq!(
528 get_claude_config_dir(),
529 PathBuf::from("/tmp/settings-override")
530 );
531
532 crate::settings::update_settings(original_settings).unwrap();
533 set_test_home_override(None);
534 }
535
536 #[test]
539 fn migration_copies_config_json_and_db() {
540 let _guard = lock_test_home_and_settings();
541 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
542 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
543
544 let temp = tempfile::tempdir().expect("create temp dir");
545 let home = temp.path();
546 set_test_home_override(Some(home));
547
548 let old_dir = home.join(".cc-switch");
549 let new_dir = home.join(".cc-switch-tui");
550 let marker = new_dir.join(".migrated-from-cc-switch");
551
552 fs::create_dir_all(old_dir.join("skills")).unwrap();
553 fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
554 fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
555 fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
556
557 migrate_legacy_config_dir_if_needed();
558
559 assert!(
560 new_dir.join("config.json").exists(),
561 "config.json should be copied"
562 );
563 assert!(
564 new_dir.join("cc-switch.db").exists(),
565 "cc-switch.db should be copied"
566 );
567 assert!(
568 new_dir.join("skills").join("my-skill.md").exists(),
569 "skills/ should be recursively copied"
570 );
571 assert!(marker.exists(), "migration marker should be written");
572
573 set_test_home_override(None);
574 }
575
576 #[test]
577 fn migration_skips_when_target_has_marker() {
578 let _guard = lock_test_home_and_settings();
579 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
580 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
581
582 let temp = tempfile::tempdir().expect("create temp dir");
583 let home = temp.path();
584 set_test_home_override(Some(home));
585
586 let old_dir = home.join(".cc-switch");
587 let new_dir = home.join(".cc-switch-tui");
588 let marker = new_dir.join(".migrated-from-cc-switch");
589
590 fs::create_dir_all(&old_dir).unwrap();
591 fs::write(old_dir.join("config.json"), "v1").unwrap();
592 fs::create_dir_all(&new_dir).unwrap();
593 fs::write(&marker, "already migrated").unwrap();
594
595 migrate_legacy_config_dir_if_needed();
596
597 assert!(
598 !new_dir.join("config.json").exists(),
599 "should not copy when marker exists"
600 );
601
602 set_test_home_override(None);
603 }
604
605 #[test]
606 fn migration_skips_when_legacy_env_override_set() {
607 let _guard = lock_test_home_and_settings();
608 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
609 let _old =
610 ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
611
612 let temp = tempfile::tempdir().expect("create temp dir");
613 let home = temp.path();
614 set_test_home_override(Some(home));
615
616 let old_dir = home.join(".cc-switch");
617 let new_dir = home.join(".cc-switch-tui");
618
619 fs::create_dir_all(&old_dir).unwrap();
620 fs::write(old_dir.join("config.json"), "v1").unwrap();
621
622 migrate_legacy_config_dir_if_needed();
623
624 assert!(
625 !new_dir.exists(),
626 "should not create target dir when env override set"
627 );
628
629 set_test_home_override(None);
630 }
631
632 #[test]
633 fn migration_uses_new_env_override_as_target() {
634 let _guard = lock_test_home_and_settings();
635 let _tui =
636 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
637 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
638
639 let temp = tempfile::tempdir().expect("create temp dir");
640 let home = temp.path();
641 set_test_home_override(Some(home));
642
643 let old_dir = home.join(".cc-switch");
644 let new_dir = home.join(".config").join("cc-switch-tui");
645 let marker = new_dir.join(".migrated-from-cc-switch");
646
647 fs::create_dir_all(&old_dir).unwrap();
648 fs::write(old_dir.join("config.json"), "v1").unwrap();
649
650 assert_eq!(
651 legacy_config_migration_paths(),
652 Some((old_dir.clone(), new_dir.clone()))
653 );
654
655 migrate_legacy_config_dir_if_needed();
656
657 assert!(new_dir.join("config.json").exists());
658 assert!(marker.exists());
659
660 set_test_home_override(None);
661 }
662
663 #[test]
664 fn migration_skips_when_target_already_has_contents() {
665 let _guard = lock_test_home_and_settings();
666 let _tui =
667 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
668 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
669
670 let temp = tempfile::tempdir().expect("create temp dir");
671 let home = temp.path();
672 set_test_home_override(Some(home));
673
674 let old_dir = home.join(".cc-switch");
675 let new_dir = home.join(".config").join("cc-switch-tui");
676
677 fs::create_dir_all(&old_dir).unwrap();
678 fs::write(old_dir.join("config.json"), "legacy").unwrap();
679 fs::create_dir_all(&new_dir).unwrap();
680 fs::write(new_dir.join("config.json"), "current").unwrap();
681
682 assert_eq!(legacy_config_migration_paths(), None);
683
684 migrate_legacy_config_dir_if_needed();
685
686 assert_eq!(
687 fs::read_to_string(new_dir.join("config.json")).unwrap(),
688 "current"
689 );
690 assert!(!new_dir.join(".migrated-from-cc-switch").exists());
691
692 set_test_home_override(None);
693 }
694
695 #[test]
696 fn migration_copies_settings_json_when_target_only_has_db() {
697 let _guard = lock_test_home_and_settings();
698 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
699 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
700
701 let temp = tempfile::tempdir().expect("create temp dir");
702 let home = temp.path();
703 set_test_home_override(Some(home));
704
705 let old_dir = home.join(".cc-switch");
706 let new_dir = home.join(".cc-switch-tui");
707
708 fs::create_dir_all(&old_dir).unwrap();
709 fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
710 fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
711 fs::create_dir_all(&new_dir).unwrap();
712 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
713
714 assert_eq!(
715 legacy_config_migration_paths(),
716 Some((old_dir.clone(), new_dir.clone()))
717 );
718
719 migrate_legacy_config_dir_if_needed();
720
721 assert_eq!(
722 fs::read_to_string(new_dir.join("settings.json")).unwrap(),
723 "legacy-settings"
724 );
725 assert_eq!(
726 fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
727 "current-db",
728 "existing target database must not be overwritten"
729 );
730 assert!(new_dir.join(".migrated-from-cc-switch").exists());
731
732 set_test_home_override(None);
733 }
734
735 #[test]
736 fn migration_repairs_missing_settings_json_after_success_marker() {
737 let _guard = lock_test_home_and_settings();
738 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
739 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
740
741 let temp = tempfile::tempdir().expect("create temp dir");
742 let home = temp.path();
743 set_test_home_override(Some(home));
744
745 let old_dir = home.join(".cc-switch");
746 let new_dir = home.join(".cc-switch-tui");
747 let marker = new_dir.join(".migrated-from-cc-switch");
748
749 fs::create_dir_all(&old_dir).unwrap();
750 fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
751 fs::create_dir_all(&new_dir).unwrap();
752 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
753 fs::write(&marker, "Migrated from old path").unwrap();
754
755 assert_eq!(
756 legacy_config_migration_paths(),
757 None,
758 "already-migrated directories should not prompt again"
759 );
760
761 migrate_legacy_config_dir_if_needed();
762
763 assert_eq!(
764 fs::read_to_string(new_dir.join("settings.json")).unwrap(),
765 "legacy-settings"
766 );
767 assert_eq!(
768 fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
769 "current-db",
770 "repair must not overwrite the existing target database"
771 );
772
773 set_test_home_override(None);
774 }
775
776 #[test]
777 fn migration_is_idempotent() {
778 let _guard = lock_test_home_and_settings();
779 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
780 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
781
782 let temp = tempfile::tempdir().expect("create temp dir");
783 let home = temp.path();
784 set_test_home_override(Some(home));
785
786 let old_dir = home.join(".cc-switch");
787 let new_dir = home.join(".cc-switch-tui");
788 let marker = new_dir.join(".migrated-from-cc-switch");
789
790 fs::create_dir_all(&old_dir).unwrap();
791 fs::write(old_dir.join("config.json"), "v1").unwrap();
792
793 migrate_legacy_config_dir_if_needed();
795 assert!(new_dir.join("config.json").exists());
796 let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
797
798 migrate_legacy_config_dir_if_needed();
800 let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
801 assert_eq!(
802 mtime_after_first, mtime_after_second,
803 "second migration should not overwrite marker"
804 );
805
806 set_test_home_override(None);
807 }
808
809 #[test]
810 fn migration_preserves_old_directory() {
811 let _guard = lock_test_home_and_settings();
812 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
813 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
814
815 let temp = tempfile::tempdir().expect("create temp dir");
816 let home = temp.path();
817 set_test_home_override(Some(home));
818
819 let old_dir = home.join(".cc-switch");
820
821 fs::create_dir_all(&old_dir).unwrap();
822 fs::write(old_dir.join("config.json"), "v1").unwrap();
823
824 migrate_legacy_config_dir_if_needed();
825
826 assert!(old_dir.exists(), "old directory must be preserved");
827 assert!(
828 old_dir.join("config.json").exists(),
829 "old files must be preserved"
830 );
831
832 set_test_home_override(None);
833 }
834
835 #[test]
836 fn migration_copies_only_3_most_recent_backups() {
837 let _guard = lock_test_home_and_settings();
838 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
839 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
840
841 let temp = tempfile::tempdir().expect("create temp dir");
842 let home = temp.path();
843 set_test_home_override(Some(home));
844
845 let old_dir = home.join(".cc-switch");
846 let backup_dir = old_dir.join("backups");
847 fs::create_dir_all(&backup_dir).unwrap();
848
849 for i in 1..=5 {
851 let path = backup_dir.join(format!("backup-{}.json", i));
852 fs::write(&path, format!("backup {}", i)).unwrap();
853 std::thread::sleep(std::time::Duration::from_millis(10));
854 }
855
856 migrate_legacy_config_dir_if_needed();
857
858 let new_backup_dir = home.join(".cc-switch-tui").join("backups");
859 let copied: Vec<_> = fs::read_dir(&new_backup_dir)
860 .unwrap()
861 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
862 .collect();
863
864 assert_eq!(
865 copied.len(),
866 3,
867 "only 3 most recent backups should be copied"
868 );
869 assert!(
870 copied.contains(&"backup-3.json".to_string()),
871 "third most recent should be copied"
872 );
873 assert!(
874 copied.contains(&"backup-4.json".to_string()),
875 "second most recent should be copied"
876 );
877 assert!(
878 copied.contains(&"backup-5.json".to_string()),
879 "most recent should be copied"
880 );
881
882 set_test_home_override(None);
883 }
884}
885
886pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
888 fs::copy(from, to).map_err(|e| AppError::IoContext {
889 context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
890 source: e,
891 })?;
892 Ok(())
893}
894
895pub fn delete_file(path: &Path) -> Result<(), AppError> {
897 if path.exists() {
898 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
899 }
900 Ok(())
901}
902
903fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
905 fs::create_dir_all(dst)?;
906 for entry in fs::read_dir(src)? {
907 let entry = entry?;
908 let file_type = entry.file_type()?;
909 if file_type.is_symlink() {
910 continue;
911 }
912 let src_path = entry.path();
913 let dst_path = dst.join(entry.file_name());
914 if file_type.is_dir() {
915 copy_dir_recursive(&src_path, &dst_path)?;
916 } else if !dst_path.exists() {
917 fs::copy(&src_path, &dst_path)?;
918 }
919 }
920 Ok(())
921}
922
923fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
925 let mut entries: Vec<_> = fs::read_dir(src)?
926 .filter_map(|e| e.ok())
927 .filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
928 .collect();
929 entries.sort_by_key(|e| {
930 e.metadata()
931 .and_then(|m| m.modified())
932 .unwrap_or(std::time::UNIX_EPOCH)
933 });
934 entries.reverse();
935 entries.truncate(limit);
936
937 fs::create_dir_all(dst)?;
938 for entry in entries {
939 let src_path = entry.path();
940 let dst_path = dst.join(entry.file_name());
941 if entry.file_type().map_or(false, |t| t.is_dir()) {
942 copy_dir_recursive(&src_path, &dst_path)?;
943 } else if !dst_path.exists() {
944 fs::copy(&src_path, &dst_path)?;
945 }
946 }
947 Ok(())
948}
949
950fn target_allows_legacy_migration(new_dir: &Path) -> bool {
951 if !new_dir.exists() {
952 return true;
953 }
954 if !new_dir.is_dir() {
955 return false;
956 }
957
958 let entries = match fs::read_dir(new_dir) {
959 Ok(entries) => entries,
960 Err(_) => return false,
961 };
962
963 for entry in entries {
964 let Ok(entry) = entry else {
965 return false;
966 };
967 if entry.file_name() != "cc-switch.db" {
968 return false;
969 }
970 }
971 true
972}
973
974fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
975 ["settings.json", "config.json"]
976 .iter()
977 .any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
978}
979
980fn migration_marker_allows_repair(marker: &Path) -> bool {
981 match fs::read_to_string(marker) {
982 Ok(content) => content.starts_with("Migrated from "),
983 Err(_) => false,
984 }
985}
986
987fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
989 let home = home_dir()?;
990 let old_dir = home.join(".cc-switch");
991 let new_dir = effective_app_config_dir_without_migration(&home)?;
992 let marker = new_dir.join(".migrated-from-cc-switch");
993
994 if old_dir == new_dir {
995 return None;
996 }
997 if !old_dir.exists() || !old_dir.is_dir() {
998 return None;
999 }
1000 if marker.exists() {
1001 if !allow_repair
1002 || !migration_marker_allows_repair(&marker)
1003 || !needs_legacy_json_repair(&old_dir, &new_dir)
1004 {
1005 return None;
1006 }
1007 }
1008 let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
1009 if !has_contents {
1010 return None;
1011 }
1012 if !marker.exists() && !target_allows_legacy_migration(&new_dir) {
1013 return None;
1014 }
1015
1016 Some((old_dir, new_dir, marker))
1017}
1018
1019pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1021 migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1022}
1023
1024pub fn check_legacy_config_dir_migration_needed() -> bool {
1028 legacy_config_migration_paths().is_some()
1029}
1030
1031pub fn skip_legacy_config_dir_migration() {
1035 let (_, new_dir, marker) = match migration_guard(false) {
1036 Some(v) => v,
1037 None => return,
1038 };
1039
1040 if let Err(e) = std::fs::create_dir_all(&new_dir)
1041 .and_then(|_| std::fs::write(&marker, "User declined migration"))
1042 {
1043 eprintln!(
1044 "cc-switch: failed to write skip-migration marker at {}: {e}",
1045 marker.display()
1046 );
1047 }
1048}
1049
1050pub fn migrate_legacy_config_dir_if_needed() {
1059 let (old_dir, new_dir, marker) = match migration_guard(true) {
1060 Some(v) => v,
1061 None => return,
1062 };
1063
1064 if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
1066 eprintln!(
1067 "cc-switch: legacy config migration failed: {e} (old data preserved at {})",
1068 old_dir.display()
1069 );
1070 }
1071}
1072
1073fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
1074 fs::create_dir_all(new_dir)?;
1075
1076 for entry in fs::read_dir(old_dir)? {
1077 let entry = entry?;
1078 let file_type = entry.file_type()?;
1079 if file_type.is_symlink() {
1080 continue;
1081 }
1082 let src_path = entry.path();
1083 let file_name = entry.file_name();
1084 let dst_path = new_dir.join(&file_name);
1085
1086 if file_name == "backups" && file_type.is_dir() {
1087 copy_recent_backups(&src_path, &dst_path, 3)?;
1088 } else if file_type.is_dir() {
1089 copy_dir_recursive(&src_path, &dst_path)?;
1090 } else if !dst_path.exists() {
1091 fs::copy(&src_path, &dst_path)?;
1092 }
1093 }
1094
1095 fs::write(
1097 marker,
1098 format!(
1099 "Migrated from {} on {}",
1100 old_dir.display(),
1101 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1102 ),
1103 )?;
1104
1105 eprintln!(
1106 "cc-switch: config migrated from {} to {} (old directory preserved)",
1107 old_dir.display(),
1108 new_dir.display()
1109 );
1110 Ok(())
1111}
1112
1113#[derive(Serialize, Deserialize)]
1115pub struct ConfigStatus {
1116 pub exists: bool,
1117 pub path: String,
1118}
1119
1120pub fn get_claude_config_status() -> ConfigStatus {
1122 let path = get_claude_settings_path();
1123 ConfigStatus {
1124 exists: path.exists(),
1125 path: path.to_string_lossy().to_string(),
1126 }
1127}