use crate::app_config::MultiAppConfig;
use crate::database::Database;
use crate::error::AppError;
use crate::services::ProxyService;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
pub struct AppState {
pub db: Arc<Database>,
pub config: RwLock<MultiAppConfig>,
pub proxy_service: ProxyService,
}
impl AppState {
pub fn try_new() -> Result<Self, AppError> {
let app_config_dir = crate::config::get_app_config_dir();
let db_path = app_config_dir.join("cc-switch.db");
let config_path = app_config_dir.join("config.json");
let skills_path = app_config_dir.join("skills.json");
if db_path.exists() {
let db = Arc::new(Database::init()?);
let mut config = export_db_to_multi_app_config(&db)?;
migrate_legacy_codex_configs(&db, &mut config);
crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
&db,
&mut config,
)?;
return Self::from_parts(db, config);
}
let legacy_config = if config_path.exists() {
Some(MultiAppConfig::load()?)
} else {
None
};
let legacy_skills_index = if skills_path.exists() {
Some(load_skills_index_for_migration(&skills_path)?)
} else {
None
};
let db = Arc::new(Database::init()?);
if let Some(config) = legacy_config {
db.migrate_from_json(&config)?;
archive_legacy_file(&config_path, "migrated")?;
}
if let Some(index) = legacy_skills_index {
crate::settings::set_skill_sync_method(index.sync_method)?;
db.set_setting(
"skills_ssot_migration_pending",
if index.ssot_migration_pending {
"true"
} else {
"false"
},
)?;
for repo in &index.repos {
db.save_skill_repo(repo)?;
}
for skill in index.skills.values() {
db.save_skill(skill)?;
}
archive_legacy_file(&skills_path, "migrated")?;
}
let _ = db.init_default_skill_repos();
let mut config = export_db_to_multi_app_config(&db)?;
migrate_legacy_codex_configs(&db, &mut config);
crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
&db,
&mut config,
)?;
Self::from_parts(db, config)
}
pub fn try_new_with_startup_recovery() -> Result<Self, AppError> {
let state = Self::try_new()?;
state.import_live_provider_configs_on_startup()?;
if !state
.proxy_service
.is_running_blocking()
.map_err(AppError::Message)?
{
state
.proxy_service
.recover_takeovers_on_startup_blocking()
.map_err(AppError::Config)?;
}
Ok(state)
}
fn import_live_provider_configs_on_startup(&self) -> Result<(), AppError> {
for app_type in crate::app_config::AppType::all().filter(|app| !app.is_additive_mode()) {
match crate::services::provider::ProviderService::import_default_config(
self,
app_type.clone(),
) {
Ok(true) => log::info!(
"✓ Imported live config for {} as default provider",
app_type.as_str()
),
Ok(false) => log::debug!(
"○ {} already has providers; live import skipped",
app_type.as_str()
),
Err(error) => log::debug!(
"○ No live config to import for {}: {error}",
app_type.as_str()
),
}
}
match self.db.init_default_official_providers() {
Ok(count) if count > 0 => log::info!("✓ Seeded {count} official provider(s)"),
Ok(_) => {}
Err(error) => log::warn!("✗ Failed to seed official providers: {error}"),
}
match crate::services::provider::ProviderService::import_opencode_providers_from_live(self)
{
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenCode providers to import"),
Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"),
}
match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self)
{
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
Err(error) => log::warn!("✗ Failed to import OpenClaw providers: {error}"),
}
self.refresh_config_from_db()
}
pub fn save(&self) -> Result<(), AppError> {
let config = self.config.read().map_err(AppError::from)?;
persist_multi_app_config_to_db(&self.db, &config)
}
pub fn save_preserving_current_providers(
&self,
app_types: &[crate::app_config::AppType],
) -> Result<(), AppError> {
let config = self.config.read().map_err(AppError::from)?;
persist_multi_app_config_to_db_preserving_current_providers(&self.db, &config, app_types)
}
pub fn refresh_config_from_db(&self) -> Result<(), AppError> {
let mut config = export_db_to_multi_app_config(&self.db)?;
migrate_legacy_codex_configs(&self.db, &mut config);
crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
&self.db,
&mut config,
)?;
let mut guard = self.config.write().map_err(AppError::from)?;
*guard = config;
Ok(())
}
fn from_parts(db: Arc<Database>, config: MultiAppConfig) -> Result<Self, AppError> {
let proxy_service = ProxyService::new(db.clone());
Ok(Self {
db,
config: RwLock::new(config),
proxy_service,
})
}
}
fn export_db_to_multi_app_config(db: &Database) -> Result<MultiAppConfig, AppError> {
use crate::app_config::AppType;
use crate::provider::ProviderManager;
let mut config = MultiAppConfig::default();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
] {
let app_key = app.as_str();
let providers = db.get_all_providers(app_key)?;
let current = db.get_current_provider(app_key)?.unwrap_or_default();
let manager = ProviderManager { providers, current };
config.apps.insert(app_key.to_string(), manager);
let prompts = db.get_prompts(app_key)?;
match app {
AppType::Claude => config.prompts.claude.prompts = prompts.into_iter().collect(),
AppType::Codex => config.prompts.codex.prompts = prompts.into_iter().collect(),
AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(),
AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(),
AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(),
AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(),
}
let snippet = db.get_config_snippet(app_key)?;
config.common_config_snippets.set(&app, snippet);
}
let servers = db.get_all_mcp_servers()?;
config.mcp.servers = Some(servers.into_iter().collect());
Ok(config)
}
fn persist_multi_app_config_to_db(db: &Database, config: &MultiAppConfig) -> Result<(), AppError> {
persist_multi_app_config_to_db_preserving_current_providers(db, config, &[])
}
fn persist_multi_app_config_to_db_preserving_current_providers(
db: &Database,
config: &MultiAppConfig,
app_types: &[crate::app_config::AppType],
) -> Result<(), AppError> {
use crate::app_config::AppType;
let preserved_current_apps = app_types
.iter()
.map(crate::app_config::AppType::as_str)
.collect::<std::collections::HashSet<_>>();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
] {
let app_key = app.as_str();
let manager = config.get_manager(&app);
let desired = manager
.map(|m| {
m.providers
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
let existing = db.get_all_providers(app_key)?;
if let Some(m) = manager {
for provider in m.providers.values() {
db.save_provider(app_key, provider)?;
}
if !preserved_current_apps.contains(app_key) && !m.current.trim().is_empty() {
db.set_current_provider(app_key, &m.current)?;
}
}
for (id, _) in existing.iter() {
if !desired.contains(id) {
db.delete_provider(app_key, id)?;
}
}
let desired_prompts = match &app {
AppType::Claude => &config.prompts.claude.prompts,
AppType::Codex => &config.prompts.codex.prompts,
AppType::Gemini => &config.prompts.gemini.prompts,
AppType::OpenCode => &config.prompts.opencode.prompts,
AppType::OpenClaw => &config.prompts.openclaw.prompts,
AppType::Hermes => &config.prompts.hermes.prompts,
};
let existing_prompts = db.get_prompts(app_key)?;
for prompt in desired_prompts.values() {
db.save_prompt(app_key, prompt)?;
}
for (id, _) in existing_prompts.iter() {
if !desired_prompts.contains_key(id) {
db.delete_prompt(app_key, id)?;
}
}
db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
}
let desired_servers = config.mcp.servers.as_ref().cloned().unwrap_or_default();
let existing_servers = db.get_all_mcp_servers()?;
for server in desired_servers.values() {
db.save_mcp_server(server)?;
}
for (id, _) in existing_servers.iter() {
if !desired_servers.contains_key(id) {
db.delete_mcp_server(id)?;
}
}
Ok(())
}
fn load_skills_index_for_migration(
path: &Path,
) -> Result<crate::services::skill::SkillsIndex, AppError> {
use crate::services::skill::{InstalledSkill, SkillApps, SkillStore, SkillsIndex, SyncMethod};
let raw = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let raw = raw.trim_start_matches('\u{feff}');
let value: serde_json::Value =
serde_json::from_str(raw).map_err(|e| AppError::json(path, e))?;
if value.get("version").and_then(|v| v.as_u64()).is_some() {
let mut index: SkillsIndex =
serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
if index.version == 0 {
index.version = 1;
}
return Ok(index);
}
let legacy: SkillStore = serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
let mut index = SkillsIndex {
version: 1,
sync_method: SyncMethod::Auto,
repos: legacy.repos,
skills: std::collections::HashMap::new(),
ssot_migration_pending: true,
};
for (directory, state) in legacy.skills.into_iter() {
if !state.installed {
continue;
}
let installed_at = state.installed_at.timestamp();
let record = InstalledSkill {
id: format!("local:{directory}"),
name: directory.clone(),
description: None,
directory: directory.clone(),
readme_url: None,
repo_owner: None,
repo_name: None,
repo_branch: None,
apps: SkillApps::only(&crate::app_config::AppType::Claude),
installed_at,
};
index.skills.insert(directory, record);
}
Ok(index)
}
fn archive_legacy_file(path: &Path, suffix: &str) -> Result<Option<PathBuf>, AppError> {
if !path.exists() {
return Ok(None);
}
let file_name = path
.file_name()
.ok_or_else(|| AppError::Config("invalid file name".to_string()))?
.to_string_lossy()
.to_string();
let mut candidate = path.with_file_name(format!("{file_name}.{suffix}"));
let mut counter: u32 = 1;
while candidate.exists() {
candidate = path.with_file_name(format!("{file_name}.{suffix}.{counter}"));
counter += 1;
}
std::fs::rename(path, &candidate).map_err(|e| AppError::io(path, e))?;
Ok(Some(candidate))
}
fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) {
use crate::app_config::AppType;
use crate::services::provider::migrate_legacy_codex_config;
let manager = match config.get_manager_mut(&AppType::Codex) {
Some(m) => m,
None => return,
};
for (provider_id, provider) in manager.providers.iter_mut() {
let cfg_text = match provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
{
Some(t) => t,
None => continue,
};
if let Some(migrated) = migrate_legacy_codex_config(cfg_text, provider) {
if let Some(obj) = provider.settings_config.as_object_mut() {
obj.insert("config".to_string(), serde_json::Value::String(migrated));
}
if let Err(e) = db.update_provider_settings_config(
AppType::Codex.as_str(),
provider_id,
&provider.settings_config,
) {
log::warn!(
"Failed to persist migrated Codex config for provider '{}': {}",
provider_id,
e
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::AppState;
use crate::test_support::{
lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
};
use serde_json::json;
use serial_test::serial;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
struct EnvGuard {
_lock: TestHomeSettingsLock,
old_home: Option<OsString>,
old_userprofile: Option<OsString>,
old_config_dir: Option<OsString>,
}
impl EnvGuard {
fn set_home(home: &Path) -> Self {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
unsafe {
std::env::set_var("HOME", home);
}
unsafe {
std::env::set_var("USERPROFILE", home);
}
unsafe {
std::env::remove_var("CC_SWITCH_CONFIG_DIR");
}
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
_lock: lock,
old_home,
old_userprofile,
old_config_dir,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
Some(value) => unsafe { std::env::set_var("HOME", value) },
None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
None => unsafe { std::env::remove_var("USERPROFILE") },
}
match &self.old_config_dir {
Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
}
}
fn write_text(path: PathBuf, text: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(path, text).expect("write text file");
}
fn write_json(path: PathBuf, value: serde_json::Value) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(
path,
serde_json::to_string_pretty(&value).expect("serialize json"),
)
.expect("write json file");
}
#[test]
#[serial(home_settings)]
fn startup_imports_existing_claude_live_config_as_default_provider() {
let temp_home = TempDir::new().expect("create temp home");
let _env = EnvGuard::set_home(temp_home.path());
write_json(
crate::config::get_claude_settings_path(),
json!({
"env": { "ANTHROPIC_API_KEY": "live-key" },
"permissions": { "allow": ["Bash"] }
}),
);
let state = AppState::try_new_with_startup_recovery().expect("create startup state");
let provider = state
.db
.get_provider_by_id("default", "claude")
.expect("read provider")
.expect("default provider should be imported");
assert_eq!(
state
.db
.get_current_provider("claude")
.expect("read current provider")
.as_deref(),
Some("default")
);
assert_eq!(
provider.settings_config["env"]["ANTHROPIC_API_KEY"],
json!("live-key")
);
assert!(provider.settings_config["permissions"]["allow"].is_array());
assert!(state
.db
.get_provider_by_id("claude-official", "claude")
.expect("read official provider")
.is_some());
let config = state.config.read().expect("read refreshed config");
let manager = config
.get_manager(&crate::app_config::AppType::Claude)
.expect("claude manager");
assert_eq!(manager.current, "default");
assert!(manager.providers.contains_key("default"));
assert!(manager.providers.contains_key("claude-official"));
}
#[test]
#[serial(home_settings)]
fn startup_imports_existing_codex_live_config_as_default_provider() {
let temp_home = TempDir::new().expect("create temp home");
let _env = EnvGuard::set_home(temp_home.path());
write_json(
crate::codex_config::get_codex_auth_path(),
json!({ "OPENAI_API_KEY": "live-codex-key" }),
);
write_text(
crate::codex_config::get_codex_config_path(),
r#"model_provider = "legacy"
model = "gpt-4"
[model_providers.legacy]
base_url = "https://api.example.com/v1"
wire_api = "responses"
"#,
);
let state = AppState::try_new_with_startup_recovery().expect("create startup state");
let provider = state
.db
.get_provider_by_id("default", "codex")
.expect("read provider")
.expect("default provider should be imported");
assert_eq!(
state
.db
.get_current_provider("codex")
.expect("read current provider")
.as_deref(),
Some("default")
);
assert_eq!(
provider.settings_config["auth"]["OPENAI_API_KEY"],
json!("live-codex-key")
);
assert!(provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
.is_some_and(|text| text.contains("model_provider = \"legacy\"")));
assert!(state
.db
.get_provider_by_id("codex-official", "codex")
.expect("read official provider")
.is_some());
let config = state.config.read().expect("read refreshed config");
let manager = config
.get_manager(&crate::app_config::AppType::Codex)
.expect("codex manager");
assert_eq!(manager.current, "default");
assert!(manager.providers.contains_key("default"));
assert!(manager.providers.contains_key("codex-official"));
}
#[test]
#[serial(home_settings)]
fn startup_seeds_official_providers_when_live_config_is_absent() {
let temp_home = TempDir::new().expect("create temp home");
let _env = EnvGuard::set_home(temp_home.path());
let state = AppState::try_new_with_startup_recovery().expect("create startup state");
for (app, provider_id, name) in [
("claude", "claude-official", "Claude Official"),
("codex", "codex-official", "OpenAI Official"),
("gemini", "gemini-official", "Google Official"),
] {
let provider = state
.db
.get_provider_by_id(provider_id, app)
.expect("read official provider")
.unwrap_or_else(|| panic!("{provider_id} should be seeded"));
assert_eq!(provider.name, name);
assert_eq!(provider.category.as_deref(), Some("official"));
}
}
#[test]
#[serial(home_settings)]
fn import_default_config_runs_when_only_official_seed_exists() {
let temp_home = TempDir::new().expect("create temp home");
let _env = EnvGuard::set_home(temp_home.path());
let state = AppState::try_new_with_startup_recovery().expect("create startup state");
assert!(state
.db
.get_provider_by_id("claude-official", "claude")
.expect("read official provider")
.is_some());
write_json(
crate::config::get_claude_settings_path(),
json!({
"env": { "ANTHROPIC_API_KEY": "late-live-key" }
}),
);
let imported = crate::services::ProviderService::import_default_config(
&state,
crate::app_config::AppType::Claude,
)
.expect("import live config");
assert!(imported);
assert_eq!(
state
.db
.get_current_provider("claude")
.expect("read current provider")
.as_deref(),
Some("default")
);
}
}