Skip to main content

cc_switch_lib/
store.rs

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
8/// 全局应用状态
9pub struct AppState {
10    pub db: Arc<Database>,
11    pub config: RwLock<MultiAppConfig>,
12    pub proxy_service: ProxyService,
13}
14
15impl AppState {
16    /// 创建新的应用状态
17    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        // Validate legacy files before creating the database file.
35        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        // Now create the database and migrate.
48        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            // Migrate legacy skills index flags into upstream-aligned storage:
57            // - sync method lives in settings.json
58            // - SSOT migration pending lives in DB settings table
59            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            // repos
70            for repo in &index.repos {
71                db.save_skill_repo(repo)?;
72            }
73            // installed skills
74            for skill in index.skills.values() {
75                db.save_skill(skill)?;
76            }
77            archive_legacy_file(&skills_path, "migrated")?;
78        }
79
80        // Ensure default repos exist (insert-missing only).
81        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    /// 创建新的应用状态,并在真实进程启动路径上执行一次启动恢复。
93    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    /// 将内存中的 config 快照持久化到 SQLite(SSOT)。
180    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    /// 将内存中的 config 快照持久化到 SQLite,但保留指定应用当前供应商的 DB 选择。
186    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    /// 用数据库中的最新快照重建内存配置,供导入/恢复后的 live 同步流程复用。
195    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        // prompts
240        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        // common snippet
251        let snippet = db.get_config_snippet(app_key)?;
252        config.common_config_snippets.set(&app, snippet);
253    }
254
255    // mcp servers (unified)
256    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        // Upsert desired
300        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        // Delete removed (only within supported apps)
311        for (id, _) in existing.iter() {
312            if !desired.contains(id) {
313                db.delete_provider(app_key, id)?;
314            }
315        }
316
317        // Prompts
318        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        // Common config snippets
337        db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
338    }
339
340    // MCP servers (global, unified)
341    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    // Legacy file: SkillStore (Claude-only) -> SkillsIndex
375    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
429/// One-time migration: convert legacy flat Codex configs to the upstream
430/// `model_provider + [model_providers.<key>]` format, then move parseable
431/// whole-file `settingsConfig.config` strings into structured
432/// `settingsConfig.codex` storage and persist to DB.
433///
434/// After this runs, parseable Codex providers in memory and DB use the new
435/// structured storage shape.
436fn 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        // Persist to DB
481        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}