1use serde::{Deserialize, Serialize};
2use std::env;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Once;
7
8use crate::error::AppError;
9
10const TEST_HOME_ENV: &str = "CC_SWITCH_TEST_HOME";
11
12#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")]
13#[cfg_attr(
14 any(
15 target_os = "linux",
16 target_os = "android",
17 target_os = "freebsd",
18 target_os = "openbsd",
19 target_os = "netbsd"
20 ),
21 link_section = ".init_array"
22)]
23#[cfg_attr(windows, link_section = ".CRT$XCU")]
24#[used]
25static AUTO_TEST_HOME_INIT: extern "C" fn() = auto_test_home_ctor;
26
27extern "C" fn auto_test_home_ctor() {
28 initialize_auto_test_home_env();
29}
30
31fn ensure_auto_test_home_env() {
32 static INIT: Once = Once::new();
33 INIT.call_once(initialize_auto_test_home_env);
34}
35
36fn initialize_auto_test_home_env() {
37 if !is_test_executable() {
38 return;
39 }
40
41 let home = env::var_os(TEST_HOME_ENV)
42 .map(PathBuf::from)
43 .unwrap_or_else(default_auto_test_home);
44 let _ = fs::create_dir_all(&home);
45 env::set_var("HOME", &home);
46 #[cfg(windows)]
47 env::set_var("USERPROFILE", &home);
48 env::set_var(TEST_HOME_ENV, &home);
49 env::remove_var("CC_SWITCH_TUI_CONFIG_DIR");
50 env::remove_var("CC_SWITCH_CONFIG_DIR");
51 env::remove_var("CLAUDE_CONFIG_DIR");
52 env::remove_var("CODEX_HOME");
53 env::remove_var("HERMES_HOME");
54}
55
56fn is_test_executable() -> bool {
57 env::current_exe()
58 .ok()
59 .and_then(|path| path.parent().map(Path::to_path_buf))
60 .and_then(|path| path.file_name().map(|name| name.to_owned()))
61 .is_some_and(|name| name == "deps")
62}
63
64fn default_auto_test_home() -> PathBuf {
65 env::temp_dir().join(format!("cc-switch-test-home-{}", std::process::id()))
66}
67
68pub(crate) fn auto_test_home() -> Option<PathBuf> {
69 ensure_auto_test_home_env();
70 if !is_test_executable() {
71 return None;
72 }
73 env::var_os(TEST_HOME_ENV).map(PathBuf::from)
74}
75
76pub(crate) fn home_dir() -> Option<PathBuf> {
77 #[cfg(test)]
78 if let Some(home) = crate::test_support::test_home_override() {
79 return Some(home);
80 }
81
82 if let Some(home) = auto_test_home() {
83 return Some(home);
84 }
85
86 dirs::home_dir()
87}
88
89fn migrate_legacy_config_dir_once() {
90 use std::sync::atomic::{AtomicBool, Ordering};
92 static MIGRATED: AtomicBool = AtomicBool::new(false);
93 if !MIGRATED.swap(true, Ordering::Relaxed) {
94 migrate_legacy_config_dir_if_needed();
95 }
96}
97
98pub(crate) fn expand_tilde(path: PathBuf) -> PathBuf {
101 let lossy = path.to_string_lossy();
102 if lossy == "~" {
103 return home_dir().unwrap_or(path);
104 }
105
106 if let Some(rest) = lossy
107 .strip_prefix("~/")
108 .or_else(|| lossy.strip_prefix("~\\"))
109 {
110 if let Some(home) = home_dir() {
111 return home.join(rest);
112 }
113 }
114
115 path
116}
117
118pub fn get_claude_config_dir() -> PathBuf {
122 if let Some(dir) = claude_config_dir_from_env() {
123 return dir;
124 }
125 if let Some(custom) = crate::settings::get_claude_override_dir() {
126 return custom;
127 }
128
129 home_dir().expect("无法获取用户主目录").join(".claude")
130}
131
132pub fn get_default_claude_mcp_path() -> PathBuf {
134 home_dir().expect("无法获取用户主目录").join(".claude.json")
135}
136
137fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
138 let file_name = dir
139 .file_name()
140 .map(|name| name.to_string_lossy().to_string())?
141 .trim()
142 .to_string();
143 if file_name.is_empty() {
144 return None;
145 }
146 let parent = dir.parent().unwrap_or_else(|| Path::new(""));
147 Some(parent.join(format!("{file_name}.json")))
148}
149
150fn effective_app_config_dir_without_migration(home: &Path) -> Option<PathBuf> {
151 if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
152 let custom = PathBuf::from(custom);
153 if !custom.to_string_lossy().trim().is_empty() {
154 return Some(expand_tilde(custom));
155 }
156 }
157
158 if env::var_os("CC_SWITCH_CONFIG_DIR").is_some() {
159 return None;
160 }
161
162 Some(home.join(".cc-switch-tui"))
163}
164
165fn claude_config_dir_from_env() -> Option<PathBuf> {
166 let dir = std::env::var_os("CLAUDE_CONFIG_DIR")?;
167 let dir = PathBuf::from(dir);
168 if dir.as_os_str().is_empty() || dir.to_string_lossy().trim().is_empty() {
169 return None;
170 }
171 Some(expand_tilde(dir))
172}
173
174pub fn get_claude_mcp_path() -> PathBuf {
176 if let Some(env_dir) = claude_config_dir_from_env() {
177 if let Some(path) = derive_mcp_path_from_override(&env_dir) {
178 return path;
179 }
180 }
181 if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
182 if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
183 return path;
184 }
185 }
186 get_default_claude_mcp_path()
187}
188
189pub fn get_claude_settings_path() -> PathBuf {
191 let dir = get_claude_config_dir();
192 let settings = dir.join("settings.json");
193 if settings.exists() {
194 return settings;
195 }
196 let legacy = dir.join("claude.json");
198 if legacy.exists() {
199 return legacy;
200 }
201 settings
203}
204
205pub fn get_app_config_dir() -> PathBuf {
209 if let Some(custom) = env::var_os("CC_SWITCH_TUI_CONFIG_DIR") {
211 let custom = PathBuf::from(custom);
212 if !custom.to_string_lossy().trim().is_empty() {
213 migrate_legacy_config_dir_once();
214 return expand_tilde(custom);
215 }
216 }
217
218 if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") {
220 let custom = PathBuf::from(custom);
221 if custom.to_string_lossy().trim().is_empty() {
222 return home_dir()
223 .expect("无法获取用户主目录")
224 .join(".cc-switch-tui");
225 }
226 eprintln!("deprecated: CC_SWITCH_CONFIG_DIR is set; use CC_SWITCH_TUI_CONFIG_DIR instead");
227 return expand_tilde(custom);
228 }
229
230 let path = home_dir()
236 .expect("无法获取用户主目录")
237 .join(".cc-switch-tui");
238
239 migrate_legacy_config_dir_once();
242
243 path
244}
245
246pub fn get_app_config_path() -> PathBuf {
248 get_app_config_dir().join("config.json")
249}
250
251pub fn sanitize_provider_name(name: &str) -> String {
253 name.chars()
254 .map(|c| match c {
255 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
256 _ => c,
257 })
258 .collect::<String>()
259 .to_lowercase()
260}
261
262pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
264 let base_name = provider_name
265 .map(sanitize_provider_name)
266 .unwrap_or_else(|| sanitize_provider_name(provider_id));
267
268 get_claude_config_dir().join(format!("settings-{base_name}.json"))
269}
270
271pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
273 if !path.exists() {
274 return Err(AppError::Config(format!("文件不存在: {}", path.display())));
275 }
276
277 let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
278
279 serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
280}
281
282pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
284 if let Some(parent) = path.parent() {
286 fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
287 }
288
289 let json =
290 serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
291
292 atomic_write(path, json.as_bytes())
293}
294
295pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
297 if let Some(parent) = path.parent() {
298 fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
299 }
300 atomic_write(path, data.as_bytes())
301}
302
303pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
305 if let Some(parent) = path.parent() {
306 fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
307 }
308
309 let parent = path
310 .parent()
311 .ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
312 let mut tmp = parent.to_path_buf();
313 let file_name = path
314 .file_name()
315 .ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
316 .to_string_lossy()
317 .to_string();
318 let ts = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .unwrap_or_default()
321 .as_nanos();
322 tmp.push(format!("{file_name}.tmp.{ts}"));
323
324 {
325 let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
326 f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
327 f.flush().map_err(|e| AppError::io(&tmp, e))?;
328 }
329
330 #[cfg(unix)]
331 {
332 use std::os::unix::fs::PermissionsExt;
333 if let Ok(meta) = fs::metadata(path) {
334 let perm = meta.permissions().mode();
335 let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
336 }
337 }
338
339 #[cfg(windows)]
340 {
341 if path.exists() {
343 let _ = fs::remove_file(path);
344 }
345 fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
346 context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
347 source: e,
348 })?;
349 }
350
351 #[cfg(not(windows))]
352 {
353 fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
354 context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
355 source: e,
356 })?;
357 }
358 Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
365 use std::ffi::OsString;
366
367 struct ConfigDirEnvGuard {
368 key: String,
369 original: Option<OsString>,
370 }
371
372 impl ConfigDirEnvGuard {
373 fn new(key: &str, value: Option<&str>) -> Self {
374 let original = env::var_os(key);
375 match value {
376 Some(v) => unsafe { env::set_var(key, v) },
377 None => unsafe { env::remove_var(key) },
378 }
379 Self {
380 key: key.to_string(),
381 original,
382 }
383 }
384 }
385
386 impl Drop for ConfigDirEnvGuard {
387 fn drop(&mut self) {
388 match self.original.as_ref() {
389 Some(value) => unsafe { env::set_var(&self.key, value) },
390 None => unsafe { env::remove_var(&self.key) },
391 }
392 }
393 }
394
395 struct SettingsGuard {
396 original: crate::settings::AppSettings,
397 }
398
399 impl SettingsGuard {
400 fn with_claude_config_dir(dir: Option<&str>) -> Self {
401 let original = crate::settings::get_settings();
402 let mut settings = original.clone();
403 settings.claude_config_dir = dir.map(str::to_string);
404 crate::settings::update_settings(settings).unwrap();
405 Self { original }
406 }
407 }
408
409 impl Drop for SettingsGuard {
410 fn drop(&mut self) {
411 let _ = crate::settings::update_settings(self.original.clone());
412 }
413 }
414
415 #[test]
416 fn derive_mcp_path_from_override_preserves_folder_name() {
417 let override_dir = PathBuf::from("/tmp/profile/.claude");
418 let derived = derive_mcp_path_from_override(&override_dir)
419 .expect("should derive path for nested dir");
420 assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
421 }
422
423 #[test]
424 fn derive_mcp_path_from_override_handles_non_hidden_folder() {
425 let override_dir = PathBuf::from("/data/claude-config");
426 let derived = derive_mcp_path_from_override(&override_dir)
427 .expect("should derive path for standard dir");
428 assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
429 }
430
431 #[test]
432 fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
433 let override_dir = PathBuf::from("claude");
434 let derived = derive_mcp_path_from_override(&override_dir)
435 .expect("should derive path for single segment");
436 assert_eq!(derived, PathBuf::from("claude.json"));
437 }
438
439 #[test]
440 fn derive_mcp_path_from_root_like_dir_returns_none() {
441 let override_dir = PathBuf::from("/");
442 assert!(derive_mcp_path_from_override(&override_dir).is_none());
443 }
444
445 #[test]
446 fn get_app_config_dir_defaults_to_home_dot_cc_switch() {
447 let _guard = lock_test_home_and_settings();
448 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
449 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
450 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-default")));
451
452 assert_eq!(
453 get_app_config_dir(),
454 PathBuf::from("/tmp/cc-switch-home-default").join(".cc-switch-tui")
455 );
456
457 set_test_home_override(None);
458 }
459
460 #[test]
461 fn get_app_config_dir_uses_env_override_when_set() {
462 let _guard = lock_test_home_and_settings();
463 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
464 let _old = ConfigDirEnvGuard::new(
465 "CC_SWITCH_CONFIG_DIR",
466 Some("/tmp/cc-switch-config-override"),
467 );
468 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-ignored")));
469
470 assert_eq!(
471 get_app_config_dir(),
472 PathBuf::from("/tmp/cc-switch-config-override")
473 );
474
475 set_test_home_override(None);
476 }
477
478 #[test]
479 fn get_app_config_dir_ignores_blank_env_override() {
480 let _guard = lock_test_home_and_settings();
481 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
482 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(" "));
483 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-blank")));
484
485 assert_eq!(
486 get_app_config_dir(),
487 PathBuf::from("/tmp/cc-switch-home-blank").join(".cc-switch-tui")
488 );
489
490 set_test_home_override(None);
491 }
492
493 #[test]
494 fn get_app_config_dir_prefers_new_env_var() {
495 let _guard = lock_test_home_and_settings();
496 let _new =
497 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-new"));
498 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-old"));
499 set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
500
501 assert_eq!(
502 get_app_config_dir(),
503 PathBuf::from("/tmp/cc-switch-tui-new")
504 );
505
506 set_test_home_override(None);
507 }
508
509 #[test]
510 fn get_app_config_dir_new_env_var_alone_works() {
511 let _guard = lock_test_home_and_settings();
512 let _new =
513 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("/tmp/cc-switch-tui-alone"));
514 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
515 set_test_home_override(Some(Path::new("/tmp/cc-switch-home")));
516
517 assert_eq!(
518 get_app_config_dir(),
519 PathBuf::from("/tmp/cc-switch-tui-alone")
520 );
521
522 set_test_home_override(None);
523 }
524
525 #[test]
526 fn get_app_config_dir_expands_tilde_in_new_env_var() {
527 let _guard = lock_test_home_and_settings();
528 let _new =
529 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
530 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
531 set_test_home_override(Some(Path::new("/tmp/cc-switch-home-tilde")));
532
533 assert_eq!(
534 get_app_config_dir(),
535 PathBuf::from("/tmp/cc-switch-home-tilde")
536 .join(".config")
537 .join("cc-switch-tui")
538 );
539
540 set_test_home_override(None);
541 }
542
543 #[test]
544 fn get_claude_config_dir_expands_tilde_in_env_var() {
545 let _guard = lock_test_home_and_settings();
546 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
547 set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
548
549 assert_eq!(
550 get_claude_config_dir(),
551 PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom")
552 );
553
554 set_test_home_override(None);
555 }
556
557 #[test]
558 fn get_claude_config_dir_respects_env_var() {
559 let _guard = lock_test_home_and_settings();
560 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
561 set_test_home_override(Some(Path::new("/tmp/claude-home")));
562
563 assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/claude-custom"));
564
565 set_test_home_override(None);
566 }
567
568 #[test]
569 fn get_claude_mcp_path_respects_env_var() {
570 let _guard = lock_test_home_and_settings();
571 let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
572 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/claude-custom"));
573 set_test_home_override(Some(Path::new("/tmp/claude-home")));
574
575 assert_eq!(
576 get_claude_mcp_path(),
577 PathBuf::from("/tmp").join("claude-custom.json")
578 );
579
580 set_test_home_override(None);
581 }
582
583 #[test]
584 fn get_claude_mcp_path_expands_tilde_in_env_var() {
585 let _guard = lock_test_home_and_settings();
586 let _settings = SettingsGuard::with_claude_config_dir(None);
587 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("~/.claude-custom"));
588 set_test_home_override(Some(Path::new("/tmp/claude-home-tilde")));
589
590 assert_eq!(
591 get_claude_mcp_path(),
592 PathBuf::from("/tmp/claude-home-tilde").join(".claude-custom.json")
593 );
594
595 set_test_home_override(None);
596 }
597
598 #[test]
599 fn get_claude_mcp_path_blank_env_falls_back_to_settings() {
600 let _guard = lock_test_home_and_settings();
601 let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
602 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
603 set_test_home_override(Some(Path::new("/tmp/claude-home")));
604
605 assert_eq!(
606 get_claude_mcp_path(),
607 PathBuf::from("/tmp").join("settings-override.json")
608 );
609
610 set_test_home_override(None);
611 }
612
613 #[test]
614 fn get_claude_config_dir_ignores_blank_env_var() {
615 let _guard = lock_test_home_and_settings();
616 let _settings = SettingsGuard::with_claude_config_dir(None);
617 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
618 set_test_home_override(Some(Path::new("/tmp/claude-home-blank")));
619
620 assert_eq!(
621 get_claude_config_dir(),
622 PathBuf::from("/tmp/claude-home-blank").join(".claude")
623 );
624
625 set_test_home_override(None);
626 }
627
628 #[test]
629 fn get_claude_config_dir_falls_back_to_default_when_nothing_set() {
630 let _guard = lock_test_home_and_settings();
631 let _settings = SettingsGuard::with_claude_config_dir(None);
632 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", None);
633 set_test_home_override(Some(Path::new("/tmp/default-home")));
634
635 assert_eq!(
636 get_claude_config_dir(),
637 PathBuf::from("/tmp/default-home").join(".claude")
638 );
639
640 set_test_home_override(None);
641 }
642
643 #[test]
644 fn get_claude_config_dir_env_overrides_settings() {
645 let _guard = lock_test_home_and_settings();
646 let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
647 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some("/tmp/env-override"));
648 set_test_home_override(Some(Path::new("/tmp/home")));
649
650 assert_eq!(get_claude_config_dir(), PathBuf::from("/tmp/env-override"));
651
652 set_test_home_override(None);
653 }
654
655 #[test]
656 fn get_claude_config_dir_blank_env_falls_back_to_settings() {
657 let _guard = lock_test_home_and_settings();
658 let _settings = SettingsGuard::with_claude_config_dir(Some("/tmp/settings-override"));
659 let _env = ConfigDirEnvGuard::new("CLAUDE_CONFIG_DIR", Some(" "));
660 set_test_home_override(Some(Path::new("/tmp/home")));
661
662 assert_eq!(
663 get_claude_config_dir(),
664 PathBuf::from("/tmp/settings-override")
665 );
666
667 set_test_home_override(None);
668 }
669
670 #[test]
673 fn migration_copies_config_json_and_db() {
674 let _guard = lock_test_home_and_settings();
675 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
676 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
677
678 let temp = tempfile::tempdir().expect("create temp dir");
679 let home = temp.path();
680 set_test_home_override(Some(home));
681
682 let old_dir = home.join(".cc-switch");
683 let new_dir = home.join(".cc-switch-tui");
684 let marker = new_dir.join(".migrated-from-cc-switch");
685
686 fs::create_dir_all(old_dir.join("skills")).unwrap();
687 fs::write(old_dir.join("config.json"), r#"{"version":"1.0"}"#).unwrap();
688 fs::write(old_dir.join("cc-switch.db"), "fake-db").unwrap();
689 fs::write(old_dir.join("skills").join("my-skill.md"), "# Skill").unwrap();
690
691 migrate_legacy_config_dir_if_needed();
692
693 assert!(
694 new_dir.join("config.json").exists(),
695 "config.json should be copied"
696 );
697 assert!(
698 new_dir.join("cc-switch.db").exists(),
699 "cc-switch.db should be copied"
700 );
701 assert!(
702 new_dir.join("skills").join("my-skill.md").exists(),
703 "skills/ should be recursively copied"
704 );
705 assert!(marker.exists(), "migration marker should be written");
706
707 set_test_home_override(None);
708 }
709
710 #[test]
711 fn migration_skips_when_target_has_marker() {
712 let _guard = lock_test_home_and_settings();
713 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
714 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
715
716 let temp = tempfile::tempdir().expect("create temp dir");
717 let home = temp.path();
718 set_test_home_override(Some(home));
719
720 let old_dir = home.join(".cc-switch");
721 let new_dir = home.join(".cc-switch-tui");
722 let marker = new_dir.join(".migrated-from-cc-switch");
723
724 fs::create_dir_all(&old_dir).unwrap();
725 fs::write(old_dir.join("config.json"), "v1").unwrap();
726 fs::create_dir_all(&new_dir).unwrap();
727 fs::write(&marker, "already migrated").unwrap();
728
729 migrate_legacy_config_dir_if_needed();
730
731 assert!(
732 !new_dir.join("config.json").exists(),
733 "should not copy when marker exists"
734 );
735
736 set_test_home_override(None);
737 }
738
739 #[test]
740 fn migration_skips_when_legacy_env_override_set() {
741 let _guard = lock_test_home_and_settings();
742 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
743 let _old =
744 ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/custom-override-test"));
745
746 let temp = tempfile::tempdir().expect("create temp dir");
747 let home = temp.path();
748 set_test_home_override(Some(home));
749
750 let old_dir = home.join(".cc-switch");
751 let new_dir = home.join(".cc-switch-tui");
752
753 fs::create_dir_all(&old_dir).unwrap();
754 fs::write(old_dir.join("config.json"), "v1").unwrap();
755
756 migrate_legacy_config_dir_if_needed();
757
758 assert!(
759 !new_dir.exists(),
760 "should not create target dir when env override set"
761 );
762
763 set_test_home_override(None);
764 }
765
766 #[test]
767 fn migration_uses_new_env_override_as_target() {
768 let _guard = lock_test_home_and_settings();
769 let _tui =
770 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
771 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
772
773 let temp = tempfile::tempdir().expect("create temp dir");
774 let home = temp.path();
775 set_test_home_override(Some(home));
776
777 let old_dir = home.join(".cc-switch");
778 let new_dir = home.join(".config").join("cc-switch-tui");
779 let marker = new_dir.join(".migrated-from-cc-switch");
780
781 fs::create_dir_all(&old_dir).unwrap();
782 fs::write(old_dir.join("config.json"), "v1").unwrap();
783
784 assert_eq!(
785 legacy_config_migration_paths(),
786 Some((old_dir.clone(), new_dir.clone()))
787 );
788
789 migrate_legacy_config_dir_if_needed();
790
791 assert!(new_dir.join("config.json").exists());
792 assert!(marker.exists());
793
794 set_test_home_override(None);
795 }
796
797 #[test]
798 fn migration_skips_when_target_already_has_contents() {
799 let _guard = lock_test_home_and_settings();
800 let _tui =
801 ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", Some("~/.config/cc-switch-tui"));
802 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
803
804 let temp = tempfile::tempdir().expect("create temp dir");
805 let home = temp.path();
806 set_test_home_override(Some(home));
807
808 let old_dir = home.join(".cc-switch");
809 let new_dir = home.join(".config").join("cc-switch-tui");
810
811 fs::create_dir_all(&old_dir).unwrap();
812 fs::write(old_dir.join("config.json"), "legacy").unwrap();
813 fs::create_dir_all(&new_dir).unwrap();
814 fs::write(new_dir.join("config.json"), "current").unwrap();
815
816 assert_eq!(legacy_config_migration_paths(), None);
817
818 migrate_legacy_config_dir_if_needed();
819
820 assert_eq!(
821 fs::read_to_string(new_dir.join("config.json")).unwrap(),
822 "current"
823 );
824 assert!(!new_dir.join(".migrated-from-cc-switch").exists());
825
826 set_test_home_override(None);
827 }
828
829 #[test]
830 fn migration_copies_settings_json_when_target_only_has_db() {
831 let _guard = lock_test_home_and_settings();
832 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
833 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
834
835 let temp = tempfile::tempdir().expect("create temp dir");
836 let home = temp.path();
837 set_test_home_override(Some(home));
838
839 let old_dir = home.join(".cc-switch");
840 let new_dir = home.join(".cc-switch-tui");
841
842 fs::create_dir_all(&old_dir).unwrap();
843 fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
844 fs::write(old_dir.join("cc-switch.db"), "legacy-db").unwrap();
845 fs::create_dir_all(&new_dir).unwrap();
846 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
847
848 assert_eq!(
849 legacy_config_migration_paths(),
850 Some((old_dir.clone(), new_dir.clone()))
851 );
852
853 migrate_legacy_config_dir_if_needed();
854
855 assert_eq!(
856 fs::read_to_string(new_dir.join("settings.json")).unwrap(),
857 "legacy-settings"
858 );
859 assert_eq!(
860 fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
861 "current-db",
862 "existing target database must not be overwritten"
863 );
864 assert!(new_dir.join(".migrated-from-cc-switch").exists());
865
866 set_test_home_override(None);
867 }
868
869 #[test]
870 fn migration_repairs_missing_settings_json_after_success_marker() {
871 let _guard = lock_test_home_and_settings();
872 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
873 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
874
875 let temp = tempfile::tempdir().expect("create temp dir");
876 let home = temp.path();
877 set_test_home_override(Some(home));
878
879 let old_dir = home.join(".cc-switch");
880 let new_dir = home.join(".cc-switch-tui");
881 let marker = new_dir.join(".migrated-from-cc-switch");
882
883 fs::create_dir_all(&old_dir).unwrap();
884 fs::write(old_dir.join("settings.json"), "legacy-settings").unwrap();
885 fs::create_dir_all(&new_dir).unwrap();
886 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
887 fs::write(&marker, "Migrated from old path").unwrap();
888
889 assert_eq!(
890 legacy_config_migration_paths(),
891 None,
892 "already-migrated directories should not prompt again"
893 );
894
895 migrate_legacy_config_dir_if_needed();
896
897 assert_eq!(
898 fs::read_to_string(new_dir.join("settings.json")).unwrap(),
899 "legacy-settings"
900 );
901 assert_eq!(
902 fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
903 "current-db",
904 "repair must not overwrite the existing target database"
905 );
906
907 set_test_home_override(None);
908 }
909
910 #[test]
911 fn migration_replaces_generated_target_settings_with_legacy_preferences() {
912 let _guard = lock_test_home_and_settings();
913 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
914 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
915
916 let temp = tempfile::tempdir().expect("create temp dir");
917 let home = temp.path();
918 set_test_home_override(Some(home));
919
920 let old_dir = home.join(".cc-switch");
921 let new_dir = home.join(".cc-switch-tui");
922
923 fs::create_dir_all(&old_dir).unwrap();
924 fs::write(
925 old_dir.join("settings.json"),
926 r#"{
927 "language": "zh",
928 "visibleApps": {
929 "claude": true,
930 "codex": true,
931 "gemini": true,
932 "opencode": true,
933 "openclaw": true,
934 "hermes": true
935 },
936 "currentProviderClaude": "legacy-current"
937 }"#,
938 )
939 .unwrap();
940 fs::create_dir_all(&new_dir).unwrap();
941 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
942 fs::write(
943 new_dir.join("settings.json"),
944 r#"{
945 "language": "en",
946 "visibleApps": {
947 "claude": true,
948 "codex": true,
949 "gemini": false,
950 "opencode": true,
951 "openclaw": false,
952 "hermes": false
953 }
954 }"#,
955 )
956 .unwrap();
957
958 assert_eq!(
959 legacy_config_migration_paths(),
960 Some((old_dir.clone(), new_dir.clone()))
961 );
962
963 migrate_legacy_config_dir_if_needed();
964
965 let migrated_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
966 let value: serde_json::Value = serde_json::from_str(&migrated_settings).unwrap();
967 assert_eq!(value["language"], "zh");
968 assert_eq!(value["visibleApps"]["gemini"], true);
969 assert_eq!(value["visibleApps"]["openclaw"], true);
970 assert_eq!(value["visibleApps"]["hermes"], true);
971 assert_eq!(value["currentProviderClaude"], "legacy-current");
972 assert_eq!(
973 fs::read_to_string(new_dir.join("cc-switch.db")).unwrap(),
974 "current-db",
975 "existing target database must not be overwritten"
976 );
977 assert!(new_dir.join(".migrated-from-cc-switch").exists());
978
979 set_test_home_override(None);
980 }
981
982 #[test]
983 fn migration_does_not_replace_configured_target_settings() {
984 let _guard = lock_test_home_and_settings();
985 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
986 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
987
988 let temp = tempfile::tempdir().expect("create temp dir");
989 let home = temp.path();
990 set_test_home_override(Some(home));
991
992 let old_dir = home.join(".cc-switch");
993 let new_dir = home.join(".cc-switch-tui");
994
995 fs::create_dir_all(&old_dir).unwrap();
996 fs::write(
997 old_dir.join("settings.json"),
998 r#"{
999 "language": "zh",
1000 "visibleApps": {
1001 "claude": true,
1002 "codex": true,
1003 "gemini": true,
1004 "opencode": true,
1005 "openclaw": true,
1006 "hermes": true
1007 }
1008 }"#,
1009 )
1010 .unwrap();
1011 fs::create_dir_all(&new_dir).unwrap();
1012 fs::write(new_dir.join("cc-switch.db"), "current-db").unwrap();
1013 fs::write(
1014 new_dir.join("settings.json"),
1015 r#"{
1016 "language": "zh",
1017 "webdavSync": {
1018 "enabled": true,
1019 "baseUrl": "https://dav.example.com",
1020 "username": "user",
1021 "password": "pass"
1022 },
1023 "visibleApps": {
1024 "claude": true,
1025 "codex": true,
1026 "gemini": false,
1027 "opencode": true,
1028 "openclaw": false,
1029 "hermes": false
1030 }
1031 }"#,
1032 )
1033 .unwrap();
1034
1035 assert_eq!(
1036 legacy_config_migration_paths(),
1037 None,
1038 "configured target settings should block implicit repair"
1039 );
1040
1041 migrate_legacy_config_dir_if_needed();
1042
1043 let target_settings = fs::read_to_string(new_dir.join("settings.json")).unwrap();
1044 let value: serde_json::Value = serde_json::from_str(&target_settings).unwrap();
1045 assert_eq!(value["webdavSync"]["enabled"], true);
1046 assert_eq!(value["visibleApps"]["gemini"], false);
1047 assert!(
1048 !new_dir.join(".migrated-from-cc-switch").exists(),
1049 "blocked repair must not write the migration marker"
1050 );
1051
1052 set_test_home_override(None);
1053 }
1054
1055 #[test]
1056 fn migration_is_idempotent() {
1057 let _guard = lock_test_home_and_settings();
1058 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1059 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1060
1061 let temp = tempfile::tempdir().expect("create temp dir");
1062 let home = temp.path();
1063 set_test_home_override(Some(home));
1064
1065 let old_dir = home.join(".cc-switch");
1066 let new_dir = home.join(".cc-switch-tui");
1067 let marker = new_dir.join(".migrated-from-cc-switch");
1068
1069 fs::create_dir_all(&old_dir).unwrap();
1070 fs::write(old_dir.join("config.json"), "v1").unwrap();
1071
1072 migrate_legacy_config_dir_if_needed();
1074 assert!(new_dir.join("config.json").exists());
1075 let mtime_after_first = fs::metadata(&marker).unwrap().modified().unwrap();
1076
1077 migrate_legacy_config_dir_if_needed();
1079 let mtime_after_second = fs::metadata(&marker).unwrap().modified().unwrap();
1080 assert_eq!(
1081 mtime_after_first, mtime_after_second,
1082 "second migration should not overwrite marker"
1083 );
1084
1085 set_test_home_override(None);
1086 }
1087
1088 #[test]
1089 fn migration_preserves_old_directory() {
1090 let _guard = lock_test_home_and_settings();
1091 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1092 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1093
1094 let temp = tempfile::tempdir().expect("create temp dir");
1095 let home = temp.path();
1096 set_test_home_override(Some(home));
1097
1098 let old_dir = home.join(".cc-switch");
1099
1100 fs::create_dir_all(&old_dir).unwrap();
1101 fs::write(old_dir.join("config.json"), "v1").unwrap();
1102
1103 migrate_legacy_config_dir_if_needed();
1104
1105 assert!(old_dir.exists(), "old directory must be preserved");
1106 assert!(
1107 old_dir.join("config.json").exists(),
1108 "old files must be preserved"
1109 );
1110
1111 set_test_home_override(None);
1112 }
1113
1114 #[test]
1115 fn migration_copies_only_3_most_recent_backups() {
1116 let _guard = lock_test_home_and_settings();
1117 let _tui = ConfigDirEnvGuard::new("CC_SWITCH_TUI_CONFIG_DIR", None);
1118 let _old = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None);
1119
1120 let temp = tempfile::tempdir().expect("create temp dir");
1121 let home = temp.path();
1122 set_test_home_override(Some(home));
1123
1124 let old_dir = home.join(".cc-switch");
1125 let backup_dir = old_dir.join("backups");
1126 fs::create_dir_all(&backup_dir).unwrap();
1127
1128 for i in 1..=5 {
1130 let path = backup_dir.join(format!("backup-{}.json", i));
1131 fs::write(&path, format!("backup {}", i)).unwrap();
1132 std::thread::sleep(std::time::Duration::from_millis(10));
1133 }
1134
1135 migrate_legacy_config_dir_if_needed();
1136
1137 let new_backup_dir = home.join(".cc-switch-tui").join("backups");
1138 let copied: Vec<_> = fs::read_dir(&new_backup_dir)
1139 .unwrap()
1140 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1141 .collect();
1142
1143 assert_eq!(
1144 copied.len(),
1145 3,
1146 "only 3 most recent backups should be copied"
1147 );
1148 assert!(
1149 copied.contains(&"backup-3.json".to_string()),
1150 "third most recent should be copied"
1151 );
1152 assert!(
1153 copied.contains(&"backup-4.json".to_string()),
1154 "second most recent should be copied"
1155 );
1156 assert!(
1157 copied.contains(&"backup-5.json".to_string()),
1158 "most recent should be copied"
1159 );
1160
1161 set_test_home_override(None);
1162 }
1163}
1164
1165pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
1167 fs::copy(from, to).map_err(|e| AppError::IoContext {
1168 context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
1169 source: e,
1170 })?;
1171 Ok(())
1172}
1173
1174pub fn delete_file(path: &Path) -> Result<(), AppError> {
1176 if path.exists() {
1177 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
1178 }
1179 Ok(())
1180}
1181
1182fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
1184 fs::create_dir_all(dst)?;
1185 for entry in fs::read_dir(src)? {
1186 let entry = entry?;
1187 let file_type = entry.file_type()?;
1188 if file_type.is_symlink() {
1189 continue;
1190 }
1191 let src_path = entry.path();
1192 let dst_path = dst.join(entry.file_name());
1193 if file_type.is_dir() {
1194 copy_dir_recursive(&src_path, &dst_path)?;
1195 } else if !dst_path.exists() {
1196 fs::copy(&src_path, &dst_path)?;
1197 }
1198 }
1199 Ok(())
1200}
1201
1202fn copy_recent_backups(src: &Path, dst: &Path, limit: usize) -> std::io::Result<()> {
1204 let mut entries: Vec<_> = fs::read_dir(src)?
1205 .filter_map(|e| e.ok())
1206 .filter(|e| !e.file_type().map_or(true, |t| t.is_symlink()))
1207 .collect();
1208 entries.sort_by_key(|e| {
1209 e.metadata()
1210 .and_then(|m| m.modified())
1211 .unwrap_or(std::time::UNIX_EPOCH)
1212 });
1213 entries.reverse();
1214 entries.truncate(limit);
1215
1216 fs::create_dir_all(dst)?;
1217 for entry in entries {
1218 let src_path = entry.path();
1219 let dst_path = dst.join(entry.file_name());
1220 if entry.file_type().map_or(false, |t| t.is_dir()) {
1221 copy_dir_recursive(&src_path, &dst_path)?;
1222 } else if !dst_path.exists() {
1223 fs::copy(&src_path, &dst_path)?;
1224 }
1225 }
1226 Ok(())
1227}
1228
1229fn target_allows_legacy_migration(old_dir: &Path, new_dir: &Path) -> bool {
1230 if !new_dir.exists() {
1231 return true;
1232 }
1233 if !new_dir.is_dir() {
1234 return false;
1235 }
1236
1237 let entries = match fs::read_dir(new_dir) {
1238 Ok(entries) => entries,
1239 Err(_) => return false,
1240 };
1241
1242 for entry in entries {
1243 let Ok(entry) = entry else {
1244 return false;
1245 };
1246 let file_name = entry.file_name();
1247 if file_name == "cc-switch.db" {
1248 continue;
1249 }
1250 if file_name == "settings.json"
1251 && legacy_settings_should_replace_target(
1252 &old_dir.join("settings.json"),
1253 &new_dir.join("settings.json"),
1254 )
1255 {
1256 continue;
1257 }
1258 return false;
1259 }
1260 true
1261}
1262
1263fn needs_legacy_json_repair(old_dir: &Path, new_dir: &Path) -> bool {
1264 ["settings.json", "config.json"]
1265 .iter()
1266 .any(|file_name| old_dir.join(file_name).is_file() && !new_dir.join(file_name).exists())
1267 || legacy_settings_should_replace_target(
1268 &old_dir.join("settings.json"),
1269 &new_dir.join("settings.json"),
1270 )
1271}
1272
1273fn migration_marker_allows_repair(marker: &Path) -> bool {
1274 match fs::read_to_string(marker) {
1275 Ok(content) => content.starts_with("Migrated from "),
1276 Err(_) => false,
1277 }
1278}
1279
1280fn migration_guard(allow_repair: bool) -> Option<(PathBuf, PathBuf, PathBuf)> {
1282 let home = home_dir()?;
1283 let old_dir = home.join(".cc-switch");
1284 let new_dir = effective_app_config_dir_without_migration(&home)?;
1285 let marker = new_dir.join(".migrated-from-cc-switch");
1286
1287 if old_dir == new_dir {
1288 return None;
1289 }
1290 if !old_dir.exists() || !old_dir.is_dir() {
1291 return None;
1292 }
1293 if marker.exists() {
1294 if !allow_repair
1295 || !migration_marker_allows_repair(&marker)
1296 || !needs_legacy_json_repair(&old_dir, &new_dir)
1297 {
1298 return None;
1299 }
1300 }
1301 let has_contents = fs::read_dir(&old_dir).map_or(false, |mut rd| rd.next().is_some());
1302 if !has_contents {
1303 return None;
1304 }
1305 if !marker.exists() && !target_allows_legacy_migration(&old_dir, &new_dir) {
1306 return None;
1307 }
1308
1309 Some((old_dir, new_dir, marker))
1310}
1311
1312pub fn legacy_config_migration_paths() -> Option<(PathBuf, PathBuf)> {
1314 migration_guard(false).map(|(old_dir, new_dir, _)| (old_dir, new_dir))
1315}
1316
1317pub fn check_legacy_config_dir_migration_needed() -> bool {
1321 legacy_config_migration_paths().is_some()
1322}
1323
1324pub fn skip_legacy_config_dir_migration() {
1328 let (_, new_dir, marker) = match migration_guard(false) {
1329 Some(v) => v,
1330 None => return,
1331 };
1332
1333 if let Err(e) = std::fs::create_dir_all(&new_dir)
1334 .and_then(|_| std::fs::write(&marker, "User declined migration"))
1335 {
1336 eprintln!(
1337 "cc-switch: failed to write skip-migration marker at {}: {e}",
1338 marker.display()
1339 );
1340 }
1341}
1342
1343pub fn migrate_legacy_config_dir_if_needed() {
1352 let (old_dir, new_dir, marker) = match migration_guard(true) {
1353 Some(v) => v,
1354 None => return,
1355 };
1356
1357 if let Err(e) = try_migrate(&old_dir, &new_dir, &marker) {
1359 eprintln!(
1360 "cc-switch: legacy config migration failed: {e} (old data preserved at {})",
1361 old_dir.display()
1362 );
1363 }
1364}
1365
1366fn try_migrate(old_dir: &Path, new_dir: &Path, marker: &Path) -> std::io::Result<()> {
1367 fs::create_dir_all(new_dir)?;
1368
1369 for entry in fs::read_dir(old_dir)? {
1370 let entry = entry?;
1371 let file_type = entry.file_type()?;
1372 if file_type.is_symlink() {
1373 continue;
1374 }
1375 let src_path = entry.path();
1376 let file_name = entry.file_name();
1377 let dst_path = new_dir.join(&file_name);
1378
1379 if file_name == "backups" && file_type.is_dir() {
1380 copy_recent_backups(&src_path, &dst_path, 3)?;
1381 } else if file_type.is_dir() {
1382 copy_dir_recursive(&src_path, &dst_path)?;
1383 } else if file_name == "settings.json"
1384 && legacy_settings_should_replace_target(&src_path, &dst_path)
1385 {
1386 fs::copy(&src_path, &dst_path)?;
1387 } else if !dst_path.exists() {
1388 fs::copy(&src_path, &dst_path)?;
1389 }
1390 }
1391
1392 fs::write(
1394 marker,
1395 format!(
1396 "Migrated from {} on {}",
1397 old_dir.display(),
1398 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1399 ),
1400 )?;
1401
1402 eprintln!(
1403 "cc-switch: config migrated from {} to {} (old directory preserved)",
1404 old_dir.display(),
1405 new_dir.display()
1406 );
1407 Ok(())
1408}
1409
1410fn legacy_settings_should_replace_target(legacy_path: &Path, target_path: &Path) -> bool {
1411 if !legacy_path.is_file() {
1412 return false;
1413 }
1414 if !target_path.exists() {
1415 return true;
1416 }
1417
1418 let Ok(legacy) = read_json_value(legacy_path) else {
1419 return false;
1420 };
1421 let Ok(target) = read_json_value(target_path) else {
1422 return false;
1423 };
1424
1425 legacy_settings_has_migration_preference(&legacy)
1426 && target_settings_looks_generated(&target)
1427 && legacy_settings_are_more_specific(&legacy, &target)
1428}
1429
1430fn read_json_value(path: &Path) -> Result<serde_json::Value, serde_json::Error> {
1431 let raw = fs::read_to_string(path).map_err(serde_json::Error::io)?;
1432 serde_json::from_str(&raw)
1433}
1434
1435fn legacy_settings_has_migration_preference(value: &serde_json::Value) -> bool {
1436 language_code(value).is_some_and(|lang| lang.eq_ignore_ascii_case("zh"))
1437 || visible_apps_enabled_count(value) > default_visible_apps_enabled_count()
1438 || value
1439 .get("currentProviderClaude")
1440 .or_else(|| value.get("currentProviderCodex"))
1441 .or_else(|| value.get("currentProviderGemini"))
1442 .or_else(|| value.get("currentProviderOpencode"))
1443 .or_else(|| value.get("currentProviderOpenclaw"))
1444 .or_else(|| value.get("currentProviderHermes"))
1445 .and_then(|value| value.as_str())
1446 .is_some_and(|value| !value.trim().is_empty())
1447 || value
1448 .pointer("/webdavSync/enabled")
1449 .and_then(|value| value.as_bool())
1450 .unwrap_or(false)
1451}
1452
1453fn target_settings_looks_generated(value: &serde_json::Value) -> bool {
1454 let language_is_default = language_code(value)
1457 .map(|lang| lang.eq_ignore_ascii_case("en"))
1458 .unwrap_or(true);
1459
1460 language_is_default
1461 && current_provider_fields_empty(value)
1462 && !value
1463 .pointer("/webdavSync/enabled")
1464 .and_then(|value| value.as_bool())
1465 .unwrap_or(false)
1466 && value
1467 .get("skillSyncMethod")
1468 .and_then(|value| value.as_str())
1469 .map(|method| method == "auto")
1470 .unwrap_or(true)
1471}
1472
1473fn legacy_settings_are_more_specific(
1474 legacy: &serde_json::Value,
1475 target: &serde_json::Value,
1476) -> bool {
1477 let language_is_more_specific = match (language_code(legacy), language_code(target)) {
1478 (Some(legacy), Some(target)) => !legacy.eq_ignore_ascii_case(target),
1479 (Some(_), None) => true,
1480 _ => false,
1481 };
1482
1483 language_is_more_specific
1484 || visible_apps_enabled_count(legacy) > visible_apps_enabled_count(target)
1485 || !current_provider_fields_empty(legacy)
1486 || legacy
1487 .pointer("/webdavSync/enabled")
1488 .and_then(|value| value.as_bool())
1489 .unwrap_or(false)
1490}
1491
1492fn current_provider_fields_empty(value: &serde_json::Value) -> bool {
1493 [
1494 "currentProviderClaude",
1495 "currentProviderCodex",
1496 "currentProviderGemini",
1497 "currentProviderOpencode",
1498 "currentProviderOpenclaw",
1499 "currentProviderHermes",
1500 ]
1501 .iter()
1502 .all(|key| {
1503 value
1504 .get(*key)
1505 .and_then(|value| value.as_str())
1506 .map(|value| value.trim().is_empty())
1507 .unwrap_or(true)
1508 })
1509}
1510
1511fn language_code(value: &serde_json::Value) -> Option<&str> {
1512 value.get("language").and_then(|value| value.as_str())
1513}
1514
1515fn visible_apps_enabled_count(value: &serde_json::Value) -> usize {
1516 value
1517 .get("visibleApps")
1518 .and_then(|value| value.as_object())
1519 .map(|apps| {
1520 apps.values()
1521 .filter(|value| value.as_bool().unwrap_or(false))
1522 .count()
1523 })
1524 .unwrap_or(default_visible_apps_enabled_count())
1525}
1526
1527fn default_visible_apps_enabled_count() -> usize {
1528 crate::settings::default_visible_apps()
1529 .ordered_enabled()
1530 .len()
1531}