1use crate::app_config::MultiAppConfig;
2use crate::database::Database;
3use crate::error::AppError;
4use crate::services::ProxyService;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, RwLock};
7
8pub struct AppState {
10 pub db: Arc<Database>,
11 pub config: RwLock<MultiAppConfig>,
12 pub proxy_service: ProxyService,
13}
14
15impl AppState {
16 pub fn try_new() -> Result<Self, AppError> {
18 let app_config_dir = crate::config::get_app_config_dir();
19 let db_path = app_config_dir.join("cc-switch.db");
20 let config_path = app_config_dir.join("config.json");
21 let skills_path = app_config_dir.join("skills.json");
22
23 if db_path.exists() {
24 let db = Arc::new(Database::init()?);
25 let mut config = export_db_to_multi_app_config(&db)?;
26 migrate_legacy_codex_configs(&db, &mut config);
27 crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
28 &db,
29 &mut config,
30 )?;
31 return Self::from_parts(db, config);
32 }
33
34 let legacy_config = if config_path.exists() {
36 Some(MultiAppConfig::load()?)
37 } else {
38 None
39 };
40
41 let legacy_skills_index = if skills_path.exists() {
42 Some(load_skills_index_for_migration(&skills_path)?)
43 } else {
44 None
45 };
46
47 let db = Arc::new(Database::init()?);
49
50 if let Some(config) = legacy_config {
51 db.migrate_from_json(&config)?;
52 archive_legacy_file(&config_path, "migrated")?;
53 }
54
55 if let Some(index) = legacy_skills_index {
56 crate::settings::set_skill_sync_method(index.sync_method)?;
60 db.set_setting(
61 "skills_ssot_migration_pending",
62 if index.ssot_migration_pending {
63 "true"
64 } else {
65 "false"
66 },
67 )?;
68
69 for repo in &index.repos {
71 db.save_skill_repo(repo)?;
72 }
73 for skill in index.skills.values() {
75 db.save_skill(skill)?;
76 }
77 archive_legacy_file(&skills_path, "migrated")?;
78 }
79
80 let _ = db.init_default_skill_repos();
82
83 let mut config = export_db_to_multi_app_config(&db)?;
84 migrate_legacy_codex_configs(&db, &mut config);
85 crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
86 &db,
87 &mut config,
88 )?;
89 Self::from_parts(db, config)
90 }
91
92 pub fn try_new_with_startup_recovery() -> Result<Self, AppError> {
94 let state = Self::try_new()?;
95
96 state.import_live_provider_configs_on_startup()?;
97
98 if !state
99 .proxy_service
100 .is_running_blocking()
101 .map_err(AppError::Message)?
102 {
103 state
104 .proxy_service
105 .recover_takeovers_on_startup_blocking()
106 .map_err(AppError::Config)?;
107 }
108
109 Ok(state)
110 }
111
112 fn import_live_provider_configs_on_startup(&self) -> Result<(), AppError> {
113 for app_type in crate::app_config::AppType::all().filter(|app| !app.is_additive_mode()) {
114 if self
115 .proxy_service
116 .detect_takeover_in_live_config_for_app(&app_type)
117 {
118 log::debug!(
119 "○ {} live config is managed by proxy; live import skipped",
120 app_type.as_str()
121 );
122 continue;
123 }
124
125 match crate::services::provider::ProviderService::import_default_config(
126 self,
127 app_type.clone(),
128 ) {
129 Ok(true) => log::info!(
130 "✓ Imported live config for {} as default provider",
131 app_type.as_str()
132 ),
133 Ok(false) => log::debug!(
134 "○ {} already has providers; live import skipped",
135 app_type.as_str()
136 ),
137 Err(error) => log::debug!(
138 "○ No live config to import for {}: {error}",
139 app_type.as_str()
140 ),
141 }
142 }
143
144 match self.db.init_default_official_providers() {
145 Ok(count) if count > 0 => log::info!("✓ Seeded {count} official provider(s)"),
146 Ok(_) => {}
147 Err(error) => log::warn!("✗ Failed to seed official providers: {error}"),
148 }
149
150 match crate::services::provider::ProviderService::import_opencode_providers_from_live(self)
151 {
152 Ok(count) if count > 0 => {
153 log::info!("✓ Imported {count} OpenCode provider(s) from live config");
154 }
155 Ok(_) => log::debug!("○ No new OpenCode providers to import"),
156 Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"),
157 }
158
159 match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self)
160 {
161 Ok(count) if count > 0 => {
162 log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
163 }
164 Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
165 Err(error) => log::warn!("✗ Failed to import OpenClaw providers: {error}"),
166 }
167
168 match crate::services::provider::ProviderService::import_hermes_providers_from_live(self) {
169 Ok(count) if count > 0 => {
170 log::info!("✓ Imported {count} Hermes provider(s) from live config");
171 }
172 Ok(_) => log::debug!("○ No new Hermes providers to import"),
173 Err(error) => log::warn!("✗ Failed to import Hermes providers: {error}"),
174 }
175
176 self.refresh_config_from_db()
177 }
178
179 pub fn save(&self) -> Result<(), AppError> {
181 let config = self.config.read().map_err(AppError::from)?;
182 persist_multi_app_config_to_db(&self.db, &config)
183 }
184
185 pub fn save_preserving_current_providers(
187 &self,
188 app_types: &[crate::app_config::AppType],
189 ) -> Result<(), AppError> {
190 let config = self.config.read().map_err(AppError::from)?;
191 persist_multi_app_config_to_db_preserving_current_providers(&self.db, &config, app_types)
192 }
193
194 pub fn refresh_config_from_db(&self) -> Result<(), AppError> {
196 let mut config = export_db_to_multi_app_config(&self.db)?;
197 migrate_legacy_codex_configs(&self.db, &mut config);
198 crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
199 &self.db,
200 &mut config,
201 )?;
202
203 let mut guard = self.config.write().map_err(AppError::from)?;
204 *guard = config;
205 Ok(())
206 }
207
208 fn from_parts(db: Arc<Database>, config: MultiAppConfig) -> Result<Self, AppError> {
209 let proxy_service = ProxyService::new(db.clone());
210
211 Ok(Self {
212 db,
213 config: RwLock::new(config),
214 proxy_service,
215 })
216 }
217}
218
219fn export_db_to_multi_app_config(db: &Database) -> Result<MultiAppConfig, AppError> {
220 use crate::app_config::AppType;
221 use crate::provider::ProviderManager;
222
223 let mut config = MultiAppConfig::default();
224
225 for app in [
226 AppType::Claude,
227 AppType::Codex,
228 AppType::Gemini,
229 AppType::OpenCode,
230 AppType::OpenClaw,
231 AppType::Hermes,
232 ] {
233 let app_key = app.as_str();
234 let providers = db.get_all_providers(app_key)?;
235 let current = db.get_current_provider(app_key)?.unwrap_or_default();
236 let manager = ProviderManager { providers, current };
237 config.apps.insert(app_key.to_string(), manager);
238
239 let prompts = db.get_prompts(app_key)?;
241 match app {
242 AppType::Claude => config.prompts.claude.prompts = prompts.into_iter().collect(),
243 AppType::Codex => config.prompts.codex.prompts = prompts.into_iter().collect(),
244 AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(),
245 AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(),
246 AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(),
247 AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(),
248 }
249
250 let snippet = db.get_config_snippet(app_key)?;
252 config.common_config_snippets.set(&app, snippet);
253 }
254
255 let servers = db.get_all_mcp_servers()?;
257 config.mcp.servers = Some(servers.into_iter().collect());
258
259 Ok(config)
260}
261
262fn persist_multi_app_config_to_db(db: &Database, config: &MultiAppConfig) -> Result<(), AppError> {
263 persist_multi_app_config_to_db_preserving_current_providers(db, config, &[])
264}
265
266fn persist_multi_app_config_to_db_preserving_current_providers(
267 db: &Database,
268 config: &MultiAppConfig,
269 app_types: &[crate::app_config::AppType],
270) -> Result<(), AppError> {
271 use crate::app_config::AppType;
272
273 let preserved_current_apps = app_types
274 .iter()
275 .map(crate::app_config::AppType::as_str)
276 .collect::<std::collections::HashSet<_>>();
277
278 for app in [
279 AppType::Claude,
280 AppType::Codex,
281 AppType::Gemini,
282 AppType::OpenCode,
283 AppType::OpenClaw,
284 AppType::Hermes,
285 ] {
286 let app_key = app.as_str();
287 let manager = config.get_manager(&app);
288
289 let desired = manager
290 .map(|m| {
291 m.providers
292 .keys()
293 .cloned()
294 .collect::<std::collections::HashSet<_>>()
295 })
296 .unwrap_or_default();
297 let existing = db.get_all_providers(app_key)?;
298
299 if let Some(m) = manager {
301 for provider in m.providers.values() {
302 db.save_provider(app_key, provider)?;
303 }
304
305 if !preserved_current_apps.contains(app_key) && !m.current.trim().is_empty() {
306 db.set_current_provider(app_key, &m.current)?;
307 }
308 }
309
310 for (id, _) in existing.iter() {
312 if !desired.contains(id) {
313 db.delete_provider(app_key, id)?;
314 }
315 }
316
317 let desired_prompts = match &app {
319 AppType::Claude => &config.prompts.claude.prompts,
320 AppType::Codex => &config.prompts.codex.prompts,
321 AppType::Gemini => &config.prompts.gemini.prompts,
322 AppType::OpenCode => &config.prompts.opencode.prompts,
323 AppType::OpenClaw => &config.prompts.openclaw.prompts,
324 AppType::Hermes => &config.prompts.hermes.prompts,
325 };
326 let existing_prompts = db.get_prompts(app_key)?;
327 for prompt in desired_prompts.values() {
328 db.save_prompt(app_key, prompt)?;
329 }
330 for (id, _) in existing_prompts.iter() {
331 if !desired_prompts.contains_key(id) {
332 db.delete_prompt(app_key, id)?;
333 }
334 }
335
336 db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
338 }
339
340 let desired_servers = config.mcp.servers.as_ref().cloned().unwrap_or_default();
342 let existing_servers = db.get_all_mcp_servers()?;
343 for server in desired_servers.values() {
344 db.save_mcp_server(server)?;
345 }
346 for (id, _) in existing_servers.iter() {
347 if !desired_servers.contains_key(id) {
348 db.delete_mcp_server(id)?;
349 }
350 }
351
352 Ok(())
353}
354
355fn load_skills_index_for_migration(
356 path: &Path,
357) -> Result<crate::services::skill::SkillsIndex, AppError> {
358 use crate::services::skill::{InstalledSkill, SkillApps, SkillStore, SkillsIndex, SyncMethod};
359
360 let raw = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
361 let raw = raw.trim_start_matches('\u{feff}');
362 let value: serde_json::Value =
363 serde_json::from_str(raw).map_err(|e| AppError::json(path, e))?;
364
365 if value.get("version").and_then(|v| v.as_u64()).is_some() {
366 let mut index: SkillsIndex =
367 serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
368 if index.version == 0 {
369 index.version = 1;
370 }
371 return Ok(index);
372 }
373
374 let legacy: SkillStore = serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
376 let mut index = SkillsIndex {
377 version: 1,
378 sync_method: SyncMethod::Auto,
379 repos: legacy.repos,
380 skills: std::collections::HashMap::new(),
381 ssot_migration_pending: true,
382 };
383
384 for (directory, state) in legacy.skills.into_iter() {
385 if !state.installed {
386 continue;
387 }
388 let installed_at = state.installed_at.timestamp();
389 let record = InstalledSkill {
390 id: format!("local:{directory}"),
391 name: directory.clone(),
392 description: None,
393 directory: directory.clone(),
394 readme_url: None,
395 repo_owner: None,
396 repo_name: None,
397 repo_branch: None,
398 apps: SkillApps::only(&crate::app_config::AppType::Claude),
399 installed_at,
400 };
401 index.skills.insert(directory, record);
402 }
403
404 Ok(index)
405}
406
407fn archive_legacy_file(path: &Path, suffix: &str) -> Result<Option<PathBuf>, AppError> {
408 if !path.exists() {
409 return Ok(None);
410 }
411
412 let file_name = path
413 .file_name()
414 .ok_or_else(|| AppError::Config("invalid file name".to_string()))?
415 .to_string_lossy()
416 .to_string();
417
418 let mut candidate = path.with_file_name(format!("{file_name}.{suffix}"));
419 let mut counter: u32 = 1;
420 while candidate.exists() {
421 candidate = path.with_file_name(format!("{file_name}.{suffix}.{counter}"));
422 counter += 1;
423 }
424
425 std::fs::rename(path, &candidate).map_err(|e| AppError::io(path, e))?;
426 Ok(Some(candidate))
427}
428
429fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) {
434 use crate::app_config::AppType;
435 use crate::services::provider::migrate_legacy_codex_config;
436
437 let manager = match config.get_manager_mut(&AppType::Codex) {
438 Some(m) => m,
439 None => return,
440 };
441
442 for (provider_id, provider) in manager.providers.iter_mut() {
443 let cfg_text = match provider
444 .settings_config
445 .get("config")
446 .and_then(|v| v.as_str())
447 {
448 Some(t) => t,
449 None => continue,
450 };
451
452 if let Some(migrated) = migrate_legacy_codex_config(cfg_text, provider) {
453 if let Some(obj) = provider.settings_config.as_object_mut() {
455 obj.insert("config".to_string(), serde_json::Value::String(migrated));
456 }
457 if let Err(e) = db.update_provider_settings_config(
459 AppType::Codex.as_str(),
460 provider_id,
461 &provider.settings_config,
462 ) {
463 log::warn!(
464 "Failed to persist migrated Codex config for provider '{}': {}",
465 provider_id,
466 e
467 );
468 }
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::AppState;
476 use crate::test_support::{
477 lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
478 };
479 use serde_json::json;
480 use serial_test::serial;
481 use std::ffi::OsString;
482 use std::path::{Path, PathBuf};
483 use tempfile::TempDir;
484
485 struct EnvGuard {
486 _lock: TestHomeSettingsLock,
487 old_home: Option<OsString>,
488 old_userprofile: Option<OsString>,
489 old_config_dir: Option<OsString>,
490 }
491
492 impl EnvGuard {
493 fn set_home(home: &Path) -> Self {
494 let lock = lock_test_home_and_settings();
495 let old_home = std::env::var_os("HOME");
496 let old_userprofile = std::env::var_os("USERPROFILE");
497 let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
498 unsafe {
499 std::env::set_var("HOME", home);
500 }
501 unsafe {
502 std::env::set_var("USERPROFILE", home);
503 }
504 unsafe {
505 std::env::remove_var("CC_SWITCH_CONFIG_DIR");
506 }
507 set_test_home_override(Some(home));
508 crate::settings::reload_test_settings();
509 Self {
510 _lock: lock,
511 old_home,
512 old_userprofile,
513 old_config_dir,
514 }
515 }
516 }
517
518 impl Drop for EnvGuard {
519 fn drop(&mut self) {
520 match &self.old_home {
521 Some(value) => unsafe { std::env::set_var("HOME", value) },
522 None => unsafe { std::env::remove_var("HOME") },
523 }
524 match &self.old_userprofile {
525 Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
526 None => unsafe { std::env::remove_var("USERPROFILE") },
527 }
528 match &self.old_config_dir {
529 Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
530 None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
531 }
532 set_test_home_override(self.old_home.as_deref().map(Path::new));
533 crate::settings::reload_test_settings();
534 }
535 }
536
537 fn write_text(path: PathBuf, text: &str) {
538 if let Some(parent) = path.parent() {
539 std::fs::create_dir_all(parent).expect("create parent dir");
540 }
541 std::fs::write(path, text).expect("write text file");
542 }
543
544 fn write_json(path: PathBuf, value: serde_json::Value) {
545 if let Some(parent) = path.parent() {
546 std::fs::create_dir_all(parent).expect("create parent dir");
547 }
548 std::fs::write(
549 path,
550 serde_json::to_string_pretty(&value).expect("serialize json"),
551 )
552 .expect("write json file");
553 }
554
555 #[test]
556 #[serial(home_settings)]
557 fn startup_imports_existing_claude_live_config_as_default_provider() {
558 let temp_home = TempDir::new().expect("create temp home");
559 let _env = EnvGuard::set_home(temp_home.path());
560
561 write_json(
562 crate::config::get_claude_settings_path(),
563 json!({
564 "env": { "ANTHROPIC_API_KEY": "live-key" },
565 "permissions": { "allow": ["Bash"] }
566 }),
567 );
568
569 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
570 let provider = state
571 .db
572 .get_provider_by_id("default", "claude")
573 .expect("read provider")
574 .expect("default provider should be imported");
575
576 assert_eq!(
577 state
578 .db
579 .get_current_provider("claude")
580 .expect("read current provider")
581 .as_deref(),
582 Some("default")
583 );
584 assert_eq!(
585 provider.settings_config["env"]["ANTHROPIC_API_KEY"],
586 json!("live-key")
587 );
588 assert!(provider.settings_config["permissions"]["allow"].is_array());
589 assert!(state
590 .db
591 .get_provider_by_id("claude-official", "claude")
592 .expect("read official provider")
593 .is_some());
594 let config = state.config.read().expect("read refreshed config");
595 let manager = config
596 .get_manager(&crate::app_config::AppType::Claude)
597 .expect("claude manager");
598 assert_eq!(manager.current, "default");
599 assert!(manager.providers.contains_key("default"));
600 assert!(manager.providers.contains_key("claude-official"));
601 }
602
603 #[test]
604 #[serial(home_settings)]
605 fn startup_imports_existing_codex_live_config_as_default_provider() {
606 let temp_home = TempDir::new().expect("create temp home");
607 let _env = EnvGuard::set_home(temp_home.path());
608
609 write_json(
610 crate::codex_config::get_codex_auth_path(),
611 json!({ "OPENAI_API_KEY": "live-codex-key" }),
612 );
613 write_text(
614 crate::codex_config::get_codex_config_path(),
615 r#"model_provider = "legacy"
616model = "gpt-4"
617
618[model_providers.legacy]
619base_url = "https://api.example.com/v1"
620wire_api = "responses"
621"#,
622 );
623
624 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
625 let provider = state
626 .db
627 .get_provider_by_id("default", "codex")
628 .expect("read provider")
629 .expect("default provider should be imported");
630
631 assert_eq!(
632 state
633 .db
634 .get_current_provider("codex")
635 .expect("read current provider")
636 .as_deref(),
637 Some("default")
638 );
639 assert_eq!(
640 provider.settings_config["auth"]["OPENAI_API_KEY"],
641 json!("live-codex-key")
642 );
643 assert!(provider
644 .settings_config
645 .get("config")
646 .and_then(|value| value.as_str())
647 .is_some_and(|text| text.contains("model_provider = \"legacy\"")));
648 assert!(state
649 .db
650 .get_provider_by_id("codex-official", "codex")
651 .expect("read official provider")
652 .is_some());
653 let config = state.config.read().expect("read refreshed config");
654 let manager = config
655 .get_manager(&crate::app_config::AppType::Codex)
656 .expect("codex manager");
657 assert_eq!(manager.current, "default");
658 assert!(manager.providers.contains_key("default"));
659 assert!(manager.providers.contains_key("codex-official"));
660 }
661
662 #[test]
663 #[serial(home_settings)]
664 fn startup_reports_codex_current_mismatch_without_rewriting_live_config() {
665 let temp_home = TempDir::new().expect("create temp home");
666 let _env = EnvGuard::set_home(temp_home.path());
667
668 write_json(
669 crate::codex_config::get_codex_auth_path(),
670 json!({ "OPENAI_API_KEY": "live-codex-key" }),
671 );
672 let live_config = r#"model_provider = "zhima-fuli"
673model = "gpt-5.5"
674model_reasoning_effort = "xhigh"
675disable_response_storage = true
676approval_mode = "auto-edit"
677check_for_update_on_startup = false
678
679[model_providers.zhima-cx]
680name = "zhima-cx"
681base_url = "https://api.cx.example/v1"
682wire_api = "responses"
683requires_openai_auth = true
684
685[model_providers.zhima-fuli]
686name = "zhima-fuli"
687base_url = "https://api.fuli.example/v1"
688wire_api = "responses"
689requires_openai_auth = true
690"#;
691 write_text(crate::codex_config::get_codex_config_path(), live_config);
692
693 let mut config = crate::app_config::MultiAppConfig::default();
694 config.common_config_snippets.codex = Some(
695 "model_reasoning_effort = \"xhigh\"\ndisable_response_storage = true\napproval_mode = \"auto-edit\"\ncheck_for_update_on_startup = false"
696 .to_string(),
697 );
698 {
699 let manager = config
700 .get_manager_mut(&crate::app_config::AppType::Codex)
701 .expect("codex manager");
702 manager.current = "db-current".to_string();
703 manager.providers.insert(
704 "db-current".to_string(),
705 crate::provider::Provider::with_id(
706 "db-current".to_string(),
707 "zhima-cx".to_string(),
708 json!({
709 "auth": { "OPENAI_API_KEY": "db-key" },
710 "config": "model_provider = \"zhima-cx\"\nmodel = \"gpt-5.5\"\n\n[model_providers.zhima-cx]\nname = \"zhima-cx\"\nbase_url = \"https://api.cx.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
711 }),
712 None,
713 ),
714 );
715 manager.providers.insert(
716 "live-current".to_string(),
717 crate::provider::Provider::with_id(
718 "live-current".to_string(),
719 "zhima-fuli".to_string(),
720 json!({
721 "auth": { "OPENAI_API_KEY": "db-key" },
722 "config": "model_provider = \"zhima-fuli\"\nmodel = \"gpt-5.5\"\n\n[model_providers.zhima-fuli]\nname = \"zhima-fuli\"\nbase_url = \"https://api.fuli.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
723 }),
724 None,
725 ),
726 );
727 }
728
729 let db = crate::database::Database::init().expect("create db");
730 db.migrate_from_json(&config).expect("seed db");
731 db.set_current_provider("codex", "db-current")
732 .expect("seed db current");
733 crate::settings::set_current_provider(
734 &crate::app_config::AppType::Codex,
735 Some("db-current"),
736 )
737 .expect("seed local current");
738 drop(db);
739
740 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
741
742 assert_eq!(
743 state
744 .db
745 .get_current_provider("codex")
746 .expect("read db current")
747 .as_deref(),
748 Some("db-current")
749 );
750 assert_eq!(
751 crate::settings::get_current_provider(&crate::app_config::AppType::Codex).as_deref(),
752 Some("db-current")
753 );
754 let config = state.config.read().expect("read refreshed config");
755 let manager = config
756 .get_manager(&crate::app_config::AppType::Codex)
757 .expect("codex manager");
758 assert_eq!(manager.current, "db-current");
759 drop(config);
760 let mismatch =
761 crate::services::provider::ProviderService::codex_current_provider_mismatch(&state)
762 .expect("read Codex current mismatch")
763 .expect("mismatch should be reported for the TUI");
764 assert_eq!(mismatch.live_provider_id, "live-current");
765 assert_eq!(mismatch.stored_provider_id, "db-current");
766 assert_eq!(
767 std::fs::read_to_string(crate::codex_config::get_codex_config_path())
768 .expect("read live config"),
769 live_config,
770 "startup current reconciliation must not rewrite live config.toml"
771 );
772 }
773
774 #[test]
775 #[serial(home_settings)]
776 fn startup_reports_codex_current_mismatch_from_exact_live_key_when_duplicate_snapshot_exists() {
777 let temp_home = TempDir::new().expect("create temp home");
778 let _env = EnvGuard::set_home(temp_home.path());
779
780 write_json(
781 crate::codex_config::get_codex_auth_path(),
782 json!({ "OPENAI_API_KEY": "live-codex-key" }),
783 );
784 let duplicate_key = "810012f0_9dd1_4b80_b4d0_7251315cfb77";
785 let live_config = format!(
786 r#"model_provider = "zhima-cx"
787model = "gpt-5.5"
788disable_response_storage = true
789
790[model_providers.{duplicate_key}]
791name = "zhima-cx"
792base_url = "https://api.cx.example/v1"
793wire_api = "responses"
794requires_openai_auth = true
795
796[model_providers.zhima-cx]
797name = "zhima-cx"
798base_url = "https://api.cx.example/v1"
799wire_api = "responses"
800requires_openai_auth = true
801"#
802 );
803 write_text(crate::codex_config::get_codex_config_path(), &live_config);
804
805 let mut config = crate::app_config::MultiAppConfig::default();
806 {
807 let manager = config
808 .get_manager_mut(&crate::app_config::AppType::Codex)
809 .expect("codex manager");
810 manager.current = "duplicate-current".to_string();
811 manager.providers.insert(
812 "duplicate-current".to_string(),
813 crate::provider::Provider::with_id(
814 "duplicate-current".to_string(),
815 "zhima-cx-free".to_string(),
816 json!({
817 "auth": { "OPENAI_API_KEY": "db-key" },
818 "config": format!(
819 "model_provider = \"{duplicate_key}\"\nmodel = \"gpt-5.5\"\n\n[model_providers.{duplicate_key}]\nname = \"zhima-cx\"\nbase_url = \"https://api.cx.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
820 )
821 }),
822 None,
823 ),
824 );
825 manager.providers.insert(
826 "live-current".to_string(),
827 crate::provider::Provider::with_id(
828 "live-current".to_string(),
829 "zhima-cx".to_string(),
830 json!({
831 "auth": { "OPENAI_API_KEY": "db-key" },
832 "config": "model_provider = \"zhima-cx\"\nmodel = \"gpt-5.5\"\n\n[model_providers.zhima-cx]\nname = \"zhima-cx\"\nbase_url = \"https://api.cx.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
833 }),
834 None,
835 ),
836 );
837 }
838
839 let db = crate::database::Database::init().expect("create db");
840 db.migrate_from_json(&config).expect("seed db");
841 db.set_current_provider("codex", "duplicate-current")
842 .expect("seed db current");
843 crate::settings::set_current_provider(
844 &crate::app_config::AppType::Codex,
845 Some("duplicate-current"),
846 )
847 .expect("seed local current");
848 drop(db);
849
850 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
851
852 assert_eq!(
853 state
854 .db
855 .get_current_provider("codex")
856 .expect("read db current")
857 .as_deref(),
858 Some("duplicate-current"),
859 "startup should leave the stored current unchanged until the user chooses"
860 );
861 assert_eq!(
862 crate::settings::get_current_provider(&crate::app_config::AppType::Codex).as_deref(),
863 Some("duplicate-current")
864 );
865 let mismatch =
866 crate::services::provider::ProviderService::codex_current_provider_mismatch(&state)
867 .expect("read Codex current mismatch")
868 .expect("mismatch should be reported for the TUI");
869 assert_eq!(
870 mismatch.live_provider_id, "live-current",
871 "mismatch detection must honor the exact live model_provider key"
872 );
873 assert_eq!(mismatch.stored_provider_id, "duplicate-current");
874 assert_eq!(
875 std::fs::read_to_string(crate::codex_config::get_codex_config_path())
876 .expect("read live config"),
877 live_config,
878 "startup current reconciliation must not rewrite live config.toml"
879 );
880 }
881
882 #[test]
883 #[serial(home_settings)]
884 fn startup_seeds_official_providers_when_live_config_is_absent() {
885 let temp_home = TempDir::new().expect("create temp home");
886 let _env = EnvGuard::set_home(temp_home.path());
887
888 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
889
890 for (app, provider_id, name) in [
891 ("claude", "claude-official", "Claude Official"),
892 ("codex", "codex-official", "OpenAI Official"),
893 ("gemini", "gemini-official", "Google Official"),
894 ] {
895 let provider = state
896 .db
897 .get_provider_by_id(provider_id, app)
898 .expect("read official provider")
899 .unwrap_or_else(|| panic!("{provider_id} should be seeded"));
900 assert_eq!(provider.name, name);
901 assert_eq!(provider.category.as_deref(), Some("official"));
902 }
903 }
904
905 #[test]
906 #[serial(home_settings)]
907 fn import_default_config_runs_when_only_official_seed_exists() {
908 let temp_home = TempDir::new().expect("create temp home");
909 let _env = EnvGuard::set_home(temp_home.path());
910
911 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
912 assert!(state
913 .db
914 .get_provider_by_id("claude-official", "claude")
915 .expect("read official provider")
916 .is_some());
917
918 write_json(
919 crate::config::get_claude_settings_path(),
920 json!({
921 "env": { "ANTHROPIC_API_KEY": "late-live-key" }
922 }),
923 );
924
925 let imported = crate::services::ProviderService::import_default_config(
926 &state,
927 crate::app_config::AppType::Claude,
928 )
929 .expect("import live config");
930
931 assert!(imported);
932 assert_eq!(
933 state
934 .db
935 .get_current_provider("claude")
936 .expect("read current provider")
937 .as_deref(),
938 Some("default")
939 );
940 }
941}