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