cc-switch-tui 0.1.3

All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes
Documentation
use super::*;
use serial_test::serial;
use std::ffi::OsString;
use std::path::Path;
use tempfile::TempDir;

use crate::test_support::{
    lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
};

struct EnvGuard {
    _lock: TestHomeSettingsLock,
    old_home: Option<OsString>,
    old_userprofile: 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");
        unsafe {
            std::env::set_var("HOME", home);
        }
        unsafe {
            std::env::set_var("USERPROFILE", home);
        }
        set_test_home_override(Some(home));
        crate::settings::reload_test_settings();
        Self {
            _lock: lock,
            old_home,
            old_userprofile,
        }
    }
}

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") },
        }
        set_test_home_override(self.old_home.as_deref().map(Path::new));
        crate::settings::reload_test_settings();
    }
}

#[test]
#[serial]
fn switch_codex_provider_writes_stored_config_directly() {
    let temp_home = TempDir::new().expect("create temp home");
    let _env = EnvGuard::set_home(temp_home.path());
    std::fs::create_dir_all(crate::codex_config::get_codex_config_dir())
        .expect("create ~/.codex (initialized)");

    let mut config = MultiAppConfig::default();
    config.ensure_app(&AppType::Codex);
    {
        let manager = config
            .get_manager_mut(&AppType::Codex)
            .expect("codex manager");
        manager.providers.insert(
            "p1".to_string(),
            Provider::with_id(
                "p1".to_string(),
                "OpenAI".to_string(),
                json!({
                    "auth": { "OPENAI_API_KEY": "sk-test" },
                    "config": "model_provider = \"openai\"\nmodel = \"gpt-4o\"\n\n[model_providers.openai]\nbase_url = \"https://api.openai.com/v1\"\nwire_api = \"chat\"\nrequires_openai_auth = true\n"
                }),
                None,
            ),
        );
    }

    let state = state_from_config(config);
    ProviderService::switch(&state, AppType::Codex, "p1").expect("switch should succeed");

    let config_text =
        std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml");
    assert!(
        config_text.contains("requires_openai_auth = true"),
        "config.toml should contain requires_openai_auth from stored config"
    );
    assert!(
        config_text.contains("base_url = \"https://api.openai.com/v1\""),
        "config.toml should contain base_url from stored config"
    );
    assert!(
        config_text.contains("model = \"gpt-4o\""),
        "config.toml should contain model from stored config"
    );
}

#[test]
#[serial]
fn switch_codex_provider_migrates_legacy_flat_config() {
    let temp_home = TempDir::new().expect("create temp home");
    let _env = EnvGuard::set_home(temp_home.path());
    std::fs::create_dir_all(crate::codex_config::get_codex_config_dir())
        .expect("create ~/.codex (initialized)");

    // Start with legacy flat format
    let legacy_config = "base_url = \"https://jp.duckcoding.com/v1\"\nmodel = \"gpt-5.1-codex\"\nwire_api = \"responses\"\nrequires_openai_auth = true";
    let mut provider = Provider::with_id(
        "custom1".to_string(),
        "DuckCoding".to_string(),
        json!({
            "auth": { "OPENAI_API_KEY": "sk-duck" },
            "config": legacy_config
        }),
        None,
    );

    // Simulate startup migration (normally done in AppState::try_new)
    if let Some(migrated) = super::migrate_legacy_codex_config(legacy_config, &provider) {
        provider
            .settings_config
            .as_object_mut()
            .unwrap()
            .insert("config".to_string(), Value::String(migrated));
    }

    let mut config = MultiAppConfig::default();
    config.ensure_app(&AppType::Codex);
    config
        .get_manager_mut(&AppType::Codex)
        .unwrap()
        .providers
        .insert("custom1".to_string(), provider);

    let state = state_from_config(config);
    ProviderService::switch(&state, AppType::Codex, "custom1").expect("switch should succeed");

    let config_text =
        std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml");
    assert!(
        config_text.contains("model_provider = "),
        "config.toml should have model_provider after migration: {config_text}"
    );
    assert!(
        config_text.contains("[model_providers."),
        "config.toml should have [model_providers.xxx] section after migration: {config_text}"
    );
    assert!(
        config_text.contains("base_url = \"https://jp.duckcoding.com/v1\""),
        "config.toml should preserve base_url after migration: {config_text}"
    );
    assert!(
        config_text.contains("model = \"gpt-5.1-codex\""),
        "config.toml should preserve model after migration: {config_text}"
    );
    assert!(
        config_text.contains("wire_api = \"responses\""),
        "config.toml should preserve wire_api after migration: {config_text}"
    );
}

#[test]
fn migrate_legacy_codex_config_noop_for_new_format() {
    let new_format = "model_provider = \"openai\"\nmodel = \"gpt-4o\"\n\n[model_providers.openai]\nbase_url = \"https://api.openai.com/v1\"\nwire_api = \"chat\"\n";
    let provider = Provider::with_id("p1".to_string(), "OpenAI".to_string(), json!({}), None);
    let result = super::migrate_legacy_codex_config(new_format, &provider);
    assert!(result.is_none(), "new format should not trigger migration");
}

#[test]
fn migrate_legacy_codex_config_converts_flat_format() {
    let legacy = "base_url = \"https://custom.com/v1\"\nmodel = \"gpt-5.1-codex\"\nwire_api = \"responses\"\nrequires_openai_auth = true";
    let provider = Provider::with_id(
        "my_provider".to_string(),
        "My Provider".to_string(),
        json!({}),
        None,
    );
    let result = super::migrate_legacy_codex_config(legacy, &provider)
        .expect("legacy format should trigger migration");
    assert!(
        result.contains("model_provider = \"my_provider\""),
        "should set model_provider from provider id: {result}"
    );
    assert!(
        result.contains("[model_providers.my_provider]"),
        "should create model_providers section: {result}"
    );
    assert!(
        result.contains("base_url = \"https://custom.com/v1\""),
        "should preserve base_url: {result}"
    );
    assert!(
        result.contains("wire_api = \"responses\""),
        "should preserve wire_api: {result}"
    );
}

#[test]
fn migrate_legacy_codex_config_preserves_extra_keys() {
    let legacy = "base_url = \"https://custom.com/v1\"\nmodel = \"gpt-5.1-codex\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true";
    let provider = Provider::with_id("test".to_string(), "Test".to_string(), json!({}), None);
    let result = super::migrate_legacy_codex_config(legacy, &provider)
        .expect("legacy format should trigger migration");
    assert!(
        result.contains("model_reasoning_effort = \"high\""),
        "should preserve model_reasoning_effort: {result}"
    );
    assert!(
        result.contains("disable_response_storage = true"),
        "should preserve disable_response_storage: {result}"
    );
}