cc-switch-tui 0.2.2

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

mod app_config {
    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    pub enum AppType {
        Claude,
        Codex,
        Gemini,
        OpenCode,
        OpenClaw,
        Hermes,
    }

    impl AppType {
        pub fn as_str(&self) -> &'static str {
            match self {
                AppType::Claude => "claude",
                AppType::Codex => "codex",
                AppType::Gemini => "gemini",
                AppType::OpenCode => "opencode",
                AppType::OpenClaw => "openclaw",
                AppType::Hermes => "hermes",
            }
        }
    }
}

mod claude_mcp {
    use crate::error::AppError;

    pub fn set_has_completed_onboarding() -> Result<(), AppError> {
        Ok(())
    }

    pub fn clear_has_completed_onboarding() -> Result<(), AppError> {
        Ok(())
    }
}

mod config {
    use std::path::PathBuf;

    pub(crate) fn home_dir() -> Option<PathBuf> {
        dirs::home_dir()
    }

    pub(crate) fn get_app_config_dir() -> PathBuf {
        if let Some(custom) = std::env::var_os("CC_SWITCH_CONFIG_DIR") {
            let custom = PathBuf::from(custom);
            if !custom.to_string_lossy().trim().is_empty() {
                return custom;
            }
        }

        home_dir().expect("无法获取用户主目录").join(".cc-switch")
    }
}

mod database {
    use super::error::AppError;
    use indexmap::IndexMap;
    use std::collections::HashMap;

    #[derive(Debug, Clone, Default)]
    pub struct Database {
        providers: HashMap<String, IndexMap<String, serde_json::Value>>,
        current: HashMap<String, String>,
    }

    impl Database {
        pub fn insert_provider(&mut self, app_type: &str, id: &str) {
            self.providers
                .entry(app_type.to_string())
                .or_default()
                .insert(id.to_string(), serde_json::json!({}));
        }

        pub fn set_db_current(&mut self, app_type: &str, id: &str) {
            self.current.insert(app_type.to_string(), id.to_string());
        }

        pub fn get_all_providers(
            &self,
            app_type: &str,
        ) -> Result<IndexMap<String, serde_json::Value>, AppError> {
            Ok(self.providers.get(app_type).cloned().unwrap_or_default())
        }

        pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
            Ok(self.current.get(app_type).cloned())
        }
    }
}

mod error {
    use std::path::Path;
    use std::sync::PoisonError;

    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum AppError {
        #[error("配置错误: {0}")]
        Config(String),
        #[error("无效输入: {0}")]
        InvalidInput(String),
        #[error("IO 错误: {path}: {source}")]
        Io {
            path: String,
            #[source]
            source: std::io::Error,
        },
        #[error("JSON 序列化失败: {source}")]
        JsonSerialize {
            #[source]
            source: serde_json::Error,
        },
        #[error("锁获取失败: {0}")]
        Lock(String),
    }

    impl AppError {
        pub fn io(path: impl AsRef<Path>, source: std::io::Error) -> Self {
            Self::Io {
                path: path.as_ref().display().to_string(),
                source,
            }
        }
    }

    impl<T> From<PoisonError<T>> for AppError {
        fn from(err: PoisonError<T>) -> Self {
            Self::Lock(err.to_string())
        }
    }
}

mod services {
    pub mod skill {
        use serde::{Deserialize, Serialize};

        #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
        #[serde(rename_all = "lowercase")]
        pub enum SyncMethod {
            #[default]
            Auto,
            Symlink,
            Copy,
        }
    }

    pub mod webdav {
        use crate::error::AppError;
        use url::Url;

        pub fn parse_base_url(raw: &str) -> Result<Url, AppError> {
            let trimmed = raw.trim().trim_end_matches('/');
            let url = Url::parse(trimmed).map_err(|e| {
                AppError::InvalidInput(format!("WebDAV base_url 不是合法 URL: {e}"))
            })?;
            let scheme = url.scheme();
            if scheme != "http" && scheme != "https" {
                return Err(AppError::InvalidInput(
                    "WebDAV base_url 仅支持 http/https".to_string(),
                ));
            }

            validate_provider_base_url(&url)?;
            Ok(url)
        }

        fn validate_provider_base_url(url: &Url) -> Result<(), AppError> {
            let Some((provider_name, dav_example)) = detect_provider(url) else {
                return Ok(());
            };

            let points_under_dav = url
                .path_segments()
                .and_then(|mut segments| segments.next())
                .is_some_and(|segment| segment == "dav");
            if points_under_dav {
                return Ok(());
            }

            Err(AppError::InvalidInput(format!(
                "{provider_name} WebDAV base_url 必须指向 /dav 下的目录,例如 {dav_example}"
            )))
        }

        fn detect_provider(url: &Url) -> Option<(&'static str, &'static str)> {
            match url.host_str()? {
                host if host.eq_ignore_ascii_case("dav.jianguoyun.com") => {
                    Some(("坚果云", "https://dav.jianguoyun.com/dav/..."))
                }
                host if host.eq_ignore_ascii_case("dav.nutstore.net") => {
                    Some(("Nutstore", "https://dav.nutstore.net/dav/..."))
                }
                _ => None,
            }
        }
    }
}

#[path = "../src/settings.rs"]
mod settings_impl;

use app_config::AppType;
use database::Database;
use settings_impl::{get_current_provider, get_effective_current_provider, set_current_provider};

struct HomeGuard {
    _temp: TempDir,
    old_home: Option<OsString>,
    old_userprofile: Option<OsString>,
}

impl HomeGuard {
    fn new() -> Self {
        let temp = tempfile::tempdir().expect("create tempdir");
        let old_home = std::env::var_os("HOME");
        let old_userprofile = std::env::var_os("USERPROFILE");
        std::env::set_var("HOME", temp.path());
        std::env::set_var("USERPROFILE", temp.path());

        Self {
            _temp: temp,
            old_home,
            old_userprofile,
        }
    }

    fn path(&self) -> &std::path::Path {
        self._temp.path()
    }
}

impl Drop for HomeGuard {
    fn drop(&mut self) {
        if let Some(value) = self.old_home.take() {
            std::env::set_var("HOME", value);
        } else {
            std::env::remove_var("HOME");
        }

        if let Some(value) = self.old_userprofile.take() {
            std::env::set_var("USERPROFILE", value);
        } else {
            std::env::remove_var("USERPROFILE");
        }
    }
}

#[test]
#[serial]
fn settings_current_provider_openclaw_matches_upstream_placeholder_behavior() {
    let _home = HomeGuard::new();

    set_current_provider(&AppType::OpenClaw, Some("local-openclaw"))
        .expect("store local openclaw provider placeholder");
    assert_eq!(
        get_current_provider(&AppType::OpenClaw).as_deref(),
        Some("local-openclaw")
    );

    let mut db = Database::default();
    db.insert_provider("openclaw", "local-openclaw");
    db.set_db_current("openclaw", "db-openclaw");

    assert_eq!(
        get_effective_current_provider(&db, &AppType::OpenClaw)
            .expect("resolve effective openclaw provider")
            .as_deref(),
        Some("local-openclaw"),
        "existing local placeholder should win while it still exists in the database"
    );

    set_current_provider(&AppType::OpenClaw, Some("missing-openclaw"))
        .expect("overwrite local openclaw placeholder");
    assert_eq!(
        get_effective_current_provider(&db, &AppType::OpenClaw)
            .expect("fallback to database current provider")
            .as_deref(),
        Some("db-openclaw"),
        "missing local placeholder should be cleared and fall back to database current"
    );
    assert_eq!(
        get_current_provider(&AppType::OpenClaw),
        None,
        "invalid local placeholder should be removed after fallback"
    );

    set_current_provider(&AppType::OpenClaw, None).expect("clear local placeholder");
    assert_eq!(get_current_provider(&AppType::OpenClaw), None);
}

#[test]
#[serial]
fn settings_current_provider_openclaw_falls_back_to_db_when_cleanup_fails() {
    let home = HomeGuard::new();

    set_current_provider(&AppType::OpenClaw, Some("missing-openclaw"))
        .expect("store invalid openclaw placeholder");

    let settings_dir = home.path().join(".cc-switch");
    std::fs::remove_dir_all(&settings_dir).expect("remove settings dir before blocking writes");
    std::fs::write(&settings_dir, "block settings writes").expect("create file to block cleanup");

    let mut db = Database::default();
    db.insert_provider("openclaw", "db-openclaw");
    db.set_db_current("openclaw", "db-openclaw");

    assert_eq!(
        get_effective_current_provider(&db, &AppType::OpenClaw)
            .expect("cleanup failure should still fall back to database current")
            .as_deref(),
        Some("db-openclaw")
    );
}