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 and persist to DB.
431///
432/// After this runs, all Codex providers in memory and DB use the new format.
433fn 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            // Update in-memory
454            if let Some(obj) = provider.settings_config.as_object_mut() {
455                obj.insert("config".to_string(), serde_json::Value::String(migrated));
456            }
457            // Persist to DB
458            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}