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 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 #[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 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 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 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
893pub 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
902pub 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
910fn 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
930fn 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
994fn 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
1026pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1028 migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1029}
1030
1031pub fn check_legacy_config_dir_migration_needed() -> bool {
1035 legacy_config_migration_paths().is_some()
1036}
1037
1038pub 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
1057pub 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 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 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#[derive(Serialize, Deserialize)]
1122pub struct ConfigStatus {
1123 pub exists: bool,
1124 pub path: String,
1125}
1126
1127pub 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}