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) {
437 use crate::app_config::AppType;
438 use crate::services::provider::migrate_legacy_codex_config;
439
440 let manager = match config.get_manager_mut(&AppType::Codex) {
441 Some(m) => m,
442 None => return,
443 };
444
445 for (provider_id, provider) in manager.providers.iter_mut() {
446 let cfg_text = match provider
447 .settings_config
448 .get("config")
449 .and_then(|v| v.as_str())
450 {
451 Some(t) => t.to_string(),
452 None => continue,
453 };
454
455 let had_structured_config = provider
456 .settings_config
457 .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
458 .is_some();
459
460 if had_structured_config {
461 if let Some(obj) = provider.settings_config.as_object_mut() {
462 obj.remove(crate::codex_config::CODEX_LEGACY_CONFIG_KEY);
463 }
464 } else {
465 let config_text = migrate_legacy_codex_config(&cfg_text, provider).unwrap_or(cfg_text);
466 if let Err(e) = crate::codex_config::set_codex_config_text_in_settings(
467 &mut provider.settings_config,
468 &config_text,
469 true,
470 ) {
471 log::warn!(
472 "Failed to convert legacy Codex config storage for provider '{}': {}",
473 provider_id,
474 e
475 );
476 continue;
477 }
478 }
479
480 if let Err(e) = db.update_provider_settings_config(
482 AppType::Codex.as_str(),
483 provider_id,
484 &provider.settings_config,
485 ) {
486 log::warn!(
487 "Failed to persist migrated Codex config for provider '{}': {}",
488 provider_id,
489 e
490 );
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::AppState;
498 use crate::test_support::{
499 lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
500 };
501 use serde_json::json;
502 use serial_test::serial;
503 use std::ffi::OsString;
504 use std::path::{Path, PathBuf};
505 use tempfile::TempDir;
506
507 struct EnvGuard {
508 _lock: TestHomeSettingsLock,
509 old_home: Option<OsString>,
510 old_userprofile: Option<OsString>,
511 old_config_dir: Option<OsString>,
512 }
513
514 impl EnvGuard {
515 fn set_home(home: &Path) -> Self {
516 let lock = lock_test_home_and_settings();
517 let old_home = std::env::var_os("HOME");
518 let old_userprofile = std::env::var_os("USERPROFILE");
519 let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
520 unsafe {
521 std::env::set_var("HOME", home);
522 }
523 unsafe {
524 std::env::set_var("USERPROFILE", home);
525 }
526 unsafe {
527 std::env::remove_var("CC_SWITCH_CONFIG_DIR");
528 }
529 set_test_home_override(Some(home));
530 crate::settings::reload_test_settings();
531 Self {
532 _lock: lock,
533 old_home,
534 old_userprofile,
535 old_config_dir,
536 }
537 }
538 }
539
540 impl Drop for EnvGuard {
541 fn drop(&mut self) {
542 match &self.old_home {
543 Some(value) => unsafe { std::env::set_var("HOME", value) },
544 None => unsafe { std::env::remove_var("HOME") },
545 }
546 match &self.old_userprofile {
547 Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
548 None => unsafe { std::env::remove_var("USERPROFILE") },
549 }
550 match &self.old_config_dir {
551 Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
552 None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
553 }
554 set_test_home_override(self.old_home.as_deref().map(Path::new));
555 crate::settings::reload_test_settings();
556 }
557 }
558
559 fn write_text(path: PathBuf, text: &str) {
560 if let Some(parent) = path.parent() {
561 std::fs::create_dir_all(parent).expect("create parent dir");
562 }
563 std::fs::write(path, text).expect("write text file");
564 }
565
566 fn write_json(path: PathBuf, value: serde_json::Value) {
567 if let Some(parent) = path.parent() {
568 std::fs::create_dir_all(parent).expect("create parent dir");
569 }
570 std::fs::write(
571 path,
572 serde_json::to_string_pretty(&value).expect("serialize json"),
573 )
574 .expect("write json file");
575 }
576
577 #[test]
578 #[serial(home_settings)]
579 fn startup_imports_existing_claude_live_config_as_default_provider() {
580 let temp_home = TempDir::new().expect("create temp home");
581 let _env = EnvGuard::set_home(temp_home.path());
582
583 write_json(
584 crate::config::get_claude_settings_path(),
585 json!({
586 "env": { "ANTHROPIC_API_KEY": "live-key" },
587 "permissions": { "allow": ["Bash"] }
588 }),
589 );
590
591 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
592 let provider = state
593 .db
594 .get_provider_by_id("default", "claude")
595 .expect("read provider")
596 .expect("default provider should be imported");
597
598 assert_eq!(
599 state
600 .db
601 .get_current_provider("claude")
602 .expect("read current provider")
603 .as_deref(),
604 Some("default")
605 );
606 assert_eq!(
607 provider.settings_config["env"]["ANTHROPIC_API_KEY"],
608 json!("live-key")
609 );
610 assert!(provider.settings_config["permissions"]["allow"].is_array());
611 assert!(state
612 .db
613 .get_provider_by_id("claude-official", "claude")
614 .expect("read official provider")
615 .is_some());
616 let config = state.config.read().expect("read refreshed config");
617 let manager = config
618 .get_manager(&crate::app_config::AppType::Claude)
619 .expect("claude manager");
620 assert_eq!(manager.current, "default");
621 assert!(manager.providers.contains_key("default"));
622 assert!(manager.providers.contains_key("claude-official"));
623 }
624
625 #[test]
626 #[serial(home_settings)]
627 fn startup_imports_existing_codex_live_config_as_default_provider() {
628 let temp_home = TempDir::new().expect("create temp home");
629 let _env = EnvGuard::set_home(temp_home.path());
630
631 write_json(
632 crate::codex_config::get_codex_auth_path(),
633 json!({ "OPENAI_API_KEY": "live-codex-key" }),
634 );
635 write_text(
636 crate::codex_config::get_codex_config_path(),
637 r#"model_provider = "legacy"
638model = "gpt-4"
639
640[model_providers.legacy]
641base_url = "https://api.example.com/v1"
642wire_api = "responses"
643"#,
644 );
645
646 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
647 let provider = state
648 .db
649 .get_provider_by_id("default", "codex")
650 .expect("read provider")
651 .expect("default provider should be imported");
652
653 assert_eq!(
654 state
655 .db
656 .get_current_provider("codex")
657 .expect("read current provider")
658 .as_deref(),
659 Some("default")
660 );
661 assert_eq!(
662 provider.settings_config["auth"]["OPENAI_API_KEY"],
663 json!("live-codex-key")
664 );
665 assert!(
666 provider.settings_config.get("config").is_none(),
667 "startup Codex live import should not persist legacy whole-file config strings"
668 );
669 assert!(
670 provider.settings_config.get("codex").is_some(),
671 "startup Codex live import should persist structured settingsConfig.codex"
672 );
673 assert!(
674 crate::codex_config::codex_config_text_from_settings(&provider.settings_config)
675 .expect("Codex settings should render to config.toml")
676 .contains("model_provider = \"legacy\"")
677 );
678 assert!(state
679 .db
680 .get_provider_by_id("codex-official", "codex")
681 .expect("read official provider")
682 .is_some());
683 let config = state.config.read().expect("read refreshed config");
684 let manager = config
685 .get_manager(&crate::app_config::AppType::Codex)
686 .expect("codex manager");
687 assert_eq!(manager.current, "default");
688 assert!(manager.providers.contains_key("default"));
689 assert!(manager.providers.contains_key("codex-official"));
690 }
691
692 #[test]
693 #[serial(home_settings)]
694 fn startup_migrates_codex_legacy_config_strings_to_structured_settings() {
695 let temp_home = TempDir::new().expect("create temp home");
696 let _env = EnvGuard::set_home(temp_home.path());
697
698 let mut config = crate::app_config::MultiAppConfig::default();
699 {
700 let manager = config
701 .get_manager_mut(&crate::app_config::AppType::Codex)
702 .expect("codex manager");
703 manager.current = "legacy-flat".to_string();
704 manager.providers.insert(
705 "legacy-flat".to_string(),
706 crate::provider::Provider::with_id(
707 "legacy-flat".to_string(),
708 "Legacy Flat".to_string(),
709 json!({
710 "auth": { "OPENAI_API_KEY": "legacy-key" },
711 "config": "base_url = \"https://legacy.example/v1\"\nmodel = \"gpt-5.4\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nmodel_reasoning_effort = \"high\"\n"
712 }),
713 None,
714 ),
715 );
716 manager.providers.insert(
717 "new-format-string".to_string(),
718 crate::provider::Provider::with_id(
719 "new-format-string".to_string(),
720 "New Format String".to_string(),
721 json!({
722 "auth": { "OPENAI_API_KEY": "new-key" },
723 "config": "model_provider = \"new-format-string\"\nmodel = \"gpt-5.4\"\n\n[model_providers.new-format-string]\nbase_url = \"https://new.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n"
724 }),
725 None,
726 ),
727 );
728 }
729
730 let db = crate::database::Database::init().expect("create db");
731 db.migrate_from_json(&config).expect("seed legacy db");
732 drop(db);
733
734 let state = AppState::try_new().expect("create startup state");
735
736 for (provider_id, expected_model_provider) in [
737 ("legacy-flat", "legacy_flat"),
738 ("new-format-string", "new-format-string"),
739 ] {
740 let provider = state
741 .db
742 .get_provider_by_id(provider_id, "codex")
743 .expect("read migrated provider")
744 .unwrap_or_else(|| panic!("{provider_id} should exist"));
745 assert!(
746 provider.settings_config.get("config").is_none(),
747 "startup migration should remove legacy whole-file config strings for {provider_id}"
748 );
749 assert!(
750 provider.settings_config.get("codex").is_some(),
751 "startup migration should store structured settingsConfig.codex for {provider_id}"
752 );
753 let config_text =
754 crate::codex_config::codex_config_text_from_settings(&provider.settings_config)
755 .expect("structured Codex settings should render to config.toml");
756 assert!(
757 config_text.contains(&format!("model_provider = \"{expected_model_provider}\"")),
758 "migrated config should preserve provider identity: {config_text}"
759 );
760 }
761
762 let guard = state.config.read().expect("read in-memory config");
763 let manager = guard
764 .get_manager(&crate::app_config::AppType::Codex)
765 .expect("codex manager");
766 let provider = manager
767 .providers
768 .get("legacy-flat")
769 .expect("legacy provider should exist in memory");
770 assert!(
771 provider.settings_config.get("config").is_none(),
772 "in-memory startup config should also use structured Codex storage"
773 );
774 }
775
776 #[test]
777 #[serial(home_settings)]
778 fn startup_reports_codex_current_mismatch_without_rewriting_live_config() {
779 let temp_home = TempDir::new().expect("create temp home");
780 let _env = EnvGuard::set_home(temp_home.path());
781
782 write_json(
783 crate::codex_config::get_codex_auth_path(),
784 json!({ "OPENAI_API_KEY": "live-codex-key" }),
785 );
786 let live_config = r#"model_provider = "zhima-fuli"
787model = "gpt-5.5"
788model_reasoning_effort = "xhigh"
789disable_response_storage = true
790approval_mode = "auto-edit"
791check_for_update_on_startup = false
792
793[model_providers.zhima-cx]
794name = "zhima-cx"
795base_url = "https://api.cx.example/v1"
796wire_api = "responses"
797requires_openai_auth = true
798
799[model_providers.zhima-fuli]
800name = "zhima-fuli"
801base_url = "https://api.fuli.example/v1"
802wire_api = "responses"
803requires_openai_auth = true
804"#;
805 write_text(crate::codex_config::get_codex_config_path(), live_config);
806
807 let mut config = crate::app_config::MultiAppConfig::default();
808 config.common_config_snippets.codex = Some(
809 "model_reasoning_effort = \"xhigh\"\ndisable_response_storage = true\napproval_mode = \"auto-edit\"\ncheck_for_update_on_startup = false"
810 .to_string(),
811 );
812 {
813 let manager = config
814 .get_manager_mut(&crate::app_config::AppType::Codex)
815 .expect("codex manager");
816 manager.current = "db-current".to_string();
817 manager.providers.insert(
818 "db-current".to_string(),
819 crate::provider::Provider::with_id(
820 "db-current".to_string(),
821 "zhima-cx".to_string(),
822 json!({
823 "auth": { "OPENAI_API_KEY": "db-key" },
824 "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"
825 }),
826 None,
827 ),
828 );
829 manager.providers.insert(
830 "live-current".to_string(),
831 crate::provider::Provider::with_id(
832 "live-current".to_string(),
833 "zhima-fuli".to_string(),
834 json!({
835 "auth": { "OPENAI_API_KEY": "db-key" },
836 "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"
837 }),
838 None,
839 ),
840 );
841 }
842
843 let db = crate::database::Database::init().expect("create db");
844 db.migrate_from_json(&config).expect("seed db");
845 db.set_current_provider("codex", "db-current")
846 .expect("seed db current");
847 crate::settings::set_current_provider(
848 &crate::app_config::AppType::Codex,
849 Some("db-current"),
850 )
851 .expect("seed local current");
852 drop(db);
853
854 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
855
856 assert_eq!(
857 state
858 .db
859 .get_current_provider("codex")
860 .expect("read db current")
861 .as_deref(),
862 Some("db-current")
863 );
864 assert_eq!(
865 crate::settings::get_current_provider(&crate::app_config::AppType::Codex).as_deref(),
866 Some("db-current")
867 );
868 let config = state.config.read().expect("read refreshed config");
869 let manager = config
870 .get_manager(&crate::app_config::AppType::Codex)
871 .expect("codex manager");
872 assert_eq!(manager.current, "db-current");
873 drop(config);
874 let mismatch =
875 crate::services::provider::ProviderService::codex_current_provider_mismatch(&state)
876 .expect("read Codex current mismatch")
877 .expect("mismatch should be reported for the TUI");
878 assert_eq!(mismatch.live_provider_id, "live-current");
879 assert_eq!(mismatch.stored_provider_id, "db-current");
880 assert_eq!(
881 std::fs::read_to_string(crate::codex_config::get_codex_config_path())
882 .expect("read live config"),
883 live_config,
884 "startup current reconciliation must not rewrite live config.toml"
885 );
886 }
887
888 #[test]
889 #[serial(home_settings)]
890 fn startup_reports_codex_current_mismatch_from_exact_live_key_when_duplicate_snapshot_exists() {
891 let temp_home = TempDir::new().expect("create temp home");
892 let _env = EnvGuard::set_home(temp_home.path());
893
894 write_json(
895 crate::codex_config::get_codex_auth_path(),
896 json!({ "OPENAI_API_KEY": "live-codex-key" }),
897 );
898 let duplicate_key = "810012f0_9dd1_4b80_b4d0_7251315cfb77";
899 let live_config = format!(
900 r#"model_provider = "zhima-cx"
901model = "gpt-5.5"
902disable_response_storage = true
903
904[model_providers.{duplicate_key}]
905name = "zhima-cx"
906base_url = "https://api.cx.example/v1"
907wire_api = "responses"
908requires_openai_auth = true
909
910[model_providers.zhima-cx]
911name = "zhima-cx"
912base_url = "https://api.cx.example/v1"
913wire_api = "responses"
914requires_openai_auth = true
915"#
916 );
917 write_text(crate::codex_config::get_codex_config_path(), &live_config);
918
919 let mut config = crate::app_config::MultiAppConfig::default();
920 {
921 let manager = config
922 .get_manager_mut(&crate::app_config::AppType::Codex)
923 .expect("codex manager");
924 manager.current = "duplicate-current".to_string();
925 manager.providers.insert(
926 "duplicate-current".to_string(),
927 crate::provider::Provider::with_id(
928 "duplicate-current".to_string(),
929 "zhima-cx-free".to_string(),
930 json!({
931 "auth": { "OPENAI_API_KEY": "db-key" },
932 "config": format!(
933 "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"
934 )
935 }),
936 None,
937 ),
938 );
939 manager.providers.insert(
940 "live-current".to_string(),
941 crate::provider::Provider::with_id(
942 "live-current".to_string(),
943 "zhima-cx".to_string(),
944 json!({
945 "auth": { "OPENAI_API_KEY": "db-key" },
946 "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"
947 }),
948 None,
949 ),
950 );
951 }
952
953 let db = crate::database::Database::init().expect("create db");
954 db.migrate_from_json(&config).expect("seed db");
955 db.set_current_provider("codex", "duplicate-current")
956 .expect("seed db current");
957 crate::settings::set_current_provider(
958 &crate::app_config::AppType::Codex,
959 Some("duplicate-current"),
960 )
961 .expect("seed local current");
962 drop(db);
963
964 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
965
966 assert_eq!(
967 state
968 .db
969 .get_current_provider("codex")
970 .expect("read db current")
971 .as_deref(),
972 Some("duplicate-current"),
973 "startup should leave the stored current unchanged until the user chooses"
974 );
975 assert_eq!(
976 crate::settings::get_current_provider(&crate::app_config::AppType::Codex).as_deref(),
977 Some("duplicate-current")
978 );
979 let mismatch =
980 crate::services::provider::ProviderService::codex_current_provider_mismatch(&state)
981 .expect("read Codex current mismatch")
982 .expect("mismatch should be reported for the TUI");
983 assert_eq!(
984 mismatch.live_provider_id, "live-current",
985 "mismatch detection must honor the exact live model_provider key"
986 );
987 assert_eq!(mismatch.stored_provider_id, "duplicate-current");
988 assert_eq!(
989 std::fs::read_to_string(crate::codex_config::get_codex_config_path())
990 .expect("read live config"),
991 live_config,
992 "startup current reconciliation must not rewrite live config.toml"
993 );
994 }
995
996 #[test]
997 #[serial(home_settings)]
998 fn startup_seeds_official_providers_when_live_config_is_absent() {
999 let temp_home = TempDir::new().expect("create temp home");
1000 let _env = EnvGuard::set_home(temp_home.path());
1001
1002 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
1003
1004 for (app, provider_id, name) in [
1005 ("claude", "claude-official", "Claude Official"),
1006 ("codex", "codex-official", "OpenAI Official"),
1007 ("gemini", "gemini-official", "Google Official"),
1008 ] {
1009 let provider = state
1010 .db
1011 .get_provider_by_id(provider_id, app)
1012 .expect("read official provider")
1013 .unwrap_or_else(|| panic!("{provider_id} should be seeded"));
1014 assert_eq!(provider.name, name);
1015 assert_eq!(provider.category.as_deref(), Some("official"));
1016 if app == "codex" {
1017 assert!(
1018 provider.settings_config.get("config").is_none(),
1019 "Codex official seed should not store legacy whole-file config strings"
1020 );
1021 assert!(
1022 provider.settings_config.get("codex").is_some(),
1023 "Codex official seed should store structured settingsConfig.codex"
1024 );
1025 }
1026 }
1027 }
1028
1029 #[test]
1030 #[serial(home_settings)]
1031 fn import_default_config_runs_when_only_official_seed_exists() {
1032 let temp_home = TempDir::new().expect("create temp home");
1033 let _env = EnvGuard::set_home(temp_home.path());
1034
1035 let state = AppState::try_new_with_startup_recovery().expect("create startup state");
1036 assert!(state
1037 .db
1038 .get_provider_by_id("claude-official", "claude")
1039 .expect("read official provider")
1040 .is_some());
1041
1042 write_json(
1043 crate::config::get_claude_settings_path(),
1044 json!({
1045 "env": { "ANTHROPIC_API_KEY": "late-live-key" }
1046 }),
1047 );
1048
1049 let imported = crate::services::ProviderService::import_default_config(
1050 &state,
1051 crate::app_config::AppType::Claude,
1052 )
1053 .expect("import live config");
1054
1055 assert!(imported);
1056 assert_eq!(
1057 state
1058 .db
1059 .get_current_provider("claude")
1060 .expect("read current provider")
1061 .as_deref(),
1062 Some("default")
1063 );
1064 }
1065}