codexswitch-cli 2.0.0

Ultra-fast, cross-platform profile switching for ChatGPT, Codex, and third-party providers
Documentation
use std::fs;
use std::path::Path;

use toml_edit::DocumentMut;

use crate::{Paths, common::write_atomic_private};

const MANAGED_CONFIG_KEYS_FIELD: &str = "managed_config_keys";

pub(crate) fn write_config_snapshot(
    paths: &Paths,
    source: &Path,
    target: &Path,
) -> Result<(), String> {
    let Some(managed_keys) = managed_config_keys(paths)? else {
        return copy_config(source, target);
    };
    let snapshot = selected_snapshot(&read_document(source)?, &managed_keys);
    write_document(target, &snapshot)
}

pub(crate) fn apply_config_snapshot(
    paths: &Paths,
    snapshot: &Path,
    target: &Path,
) -> Result<(), String> {
    let Some(managed_keys) = managed_config_keys(paths)? else {
        return copy_config(snapshot, target);
    };
    let snapshot = selected_snapshot(&read_document(snapshot)?, &managed_keys);
    let mut target_document = if target.is_file() {
        read_document(target)?
    } else {
        DocumentMut::new()
    };

    for key in &managed_keys {
        target_document.remove(key);
    }
    for key in &managed_keys {
        if let Some(item) = snapshot.get(key) {
            target_document.insert(key, item.clone());
        }
    }

    write_document(target, &target_document)
}

fn managed_config_keys(paths: &Paths) -> Result<Option<Vec<String>>, String> {
    let config_path = paths.codex.join("codexswitch").join("config.toml");
    if !config_path.is_file() {
        return Ok(None);
    }

    let config = read_document(&config_path)?;
    let Some(item) = config.get(MANAGED_CONFIG_KEYS_FIELD) else {
        return Ok(None);
    };
    normalize_keys(
        parse_key_array(item, MANAGED_CONFIG_KEYS_FIELD, &config_path)?,
        &config_path,
    )
    .map(Some)
}

fn parse_key_array(
    item: &toml_edit::Item,
    field: &str,
    config_path: &Path,
) -> Result<Vec<String>, String> {
    let Some(array) = item.as_array() else {
        return Err(format!(
            "CodexSwitch config {} field '{}' must be an array of top-level config keys.",
            config_path.display(),
            field
        ));
    };
    array
        .iter()
        .map(|value| {
            value.as_str().map(str::to_string).ok_or_else(|| {
                format!(
                    "CodexSwitch config {} field '{}' must contain only strings.",
                    config_path.display(),
                    field
                )
            })
        })
        .collect()
}

fn normalize_keys(keys: Vec<String>, config_path: &Path) -> Result<Vec<String>, String> {
    let mut normalized = Vec::new();
    for key in keys {
        let key = key.trim();
        if key.is_empty() || key.contains('.') {
            return Err(format!(
                "CodexSwitch config {} managed config key '{}' must be a non-empty top-level key.",
                config_path.display(),
                key
            ));
        }
        if !normalized.iter().any(|existing| existing == key) {
            normalized.push(key.to_string());
        }
    }
    Ok(normalized)
}

fn selected_snapshot(document: &DocumentMut, managed_keys: &[String]) -> DocumentMut {
    let mut snapshot = DocumentMut::new();
    for key in managed_keys {
        if let Some(item) = document.get(key) {
            snapshot.insert(key, item.clone());
        }
    }
    snapshot
}

fn read_document(path: &Path) -> Result<DocumentMut, String> {
    let contents = fs::read_to_string(path)
        .map_err(|err| format!("Could not read config {}: {err}", path.display()))?;
    contents
        .parse::<DocumentMut>()
        .map_err(|err| format!("Could not parse config {}: {err}", path.display()))
}

fn write_document(path: &Path, document: &DocumentMut) -> Result<(), String> {
    let mut contents = document.to_string().into_bytes();
    if !contents.ends_with(b"\n") {
        contents.push(b'\n');
    }
    write_atomic_private(path, &contents)
        .map_err(|err| format!("Could not write config {}: {err}", path.display()))
}

fn copy_config(source: &Path, target: &Path) -> Result<(), String> {
    let contents = fs::read(source)
        .map_err(|err| format!("Could not read config {}: {err}", source.display()))?;
    write_atomic_private(target, &contents)
        .map_err(|err| format!("Could not write config {}: {err}", target.display()))
}

#[cfg(test)]
mod tests {
    use super::{apply_config_snapshot, write_config_snapshot};
    use crate::test_utils::make_paths;
    use std::fs;

    #[test]
    fn default_snapshot_copies_entire_config() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        let source = dir.path().join("source.toml");
        let snapshot = dir.path().join("snapshot.toml");
        fs::write(
            &source,
            r#"
model = "custom-model"
model_provider = "custom"
approval_policy = "never"

[model_providers.custom]
base_url = "https://provider.example/v1"

[mcp_servers.keep]
command = "server"
"#,
        )
        .expect("write source");

        write_config_snapshot(&paths, &source, &snapshot).expect("write snapshot");
        let contents = fs::read_to_string(&snapshot).expect("read snapshot");

        assert!(contents.contains("model = \"custom-model\""));
        assert!(contents.contains("[model_providers.custom]"));
        assert!(contents.contains("approval_policy"));
        assert!(contents.contains("mcp_servers"));
    }

    #[test]
    fn default_apply_replaces_entire_config() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        let snapshot = dir.path().join("snapshot.toml");
        let target = dir.path().join("target.toml");
        fs::write(
            &snapshot,
            r#"
model_provider = "custom"

[model_providers.custom]
base_url = "https://provider.example/v1"
"#,
        )
        .expect("write snapshot");
        fs::write(
            &target,
            r#"
model_provider = "old"
approval_policy = "on-request"

[model_providers.old]
base_url = "https://old.example/v1"

[mcp_servers.keep]
command = "server"
"#,
        )
        .expect("write target");

        apply_config_snapshot(&paths, &snapshot, &target).expect("apply snapshot");
        let contents = fs::read_to_string(target).expect("read target");

        assert!(contents.contains("model_provider = \"custom\""));
        assert!(contents.contains("[model_providers.custom]"));
        assert!(!contents.contains("old.example"));
        assert!(!contents.contains("approval_policy"));
        assert!(!contents.contains("[mcp_servers.keep]"));
    }

    #[test]
    fn configured_keys_are_saved_and_replaced_selectively() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        let settings_dir = paths.codex.join("codexswitch");
        fs::create_dir_all(&settings_dir).expect("create settings dir");
        fs::write(
            settings_dir.join("config.toml"),
            "managed_config_keys = [\"chatgpt_base_url\"]\n",
        )
        .expect("write settings");
        let source = dir.path().join("source.toml");
        let snapshot = dir.path().join("snapshot.toml");
        let target = dir.path().join("target.toml");
        fs::write(
            &source,
            "chatgpt_base_url = \"https://new.example\"\napproval_policy = \"never\"\n",
        )
        .expect("write source");
        fs::write(
            &target,
            "chatgpt_base_url = \"https://old.example\"\napproval_policy = \"on-request\"\n",
        )
        .expect("write target");

        write_config_snapshot(&paths, &source, &snapshot).expect("write snapshot");
        let snapshot_contents = fs::read_to_string(&snapshot).expect("read snapshot");
        assert!(snapshot_contents.contains("https://new.example"));
        assert!(!snapshot_contents.contains("approval_policy"));

        apply_config_snapshot(&paths, &snapshot, &target).expect("apply snapshot");
        let contents = fs::read_to_string(target).expect("read target");

        assert!(contents.contains("https://new.example"));
        assert!(contents.contains("approval_policy = \"on-request\""));
    }

    #[test]
    fn empty_managed_key_list_saves_nothing_and_changes_nothing() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        let settings_dir = paths.codex.join("codexswitch");
        fs::create_dir_all(&settings_dir).expect("create settings dir");
        fs::write(
            settings_dir.join("config.toml"),
            "managed_config_keys = []\n",
        )
        .expect("write settings");
        let source = dir.path().join("source.toml");
        let snapshot = dir.path().join("snapshot.toml");
        let target = dir.path().join("target.toml");
        fs::write(
            &source,
            "model_provider = \"custom\"\napproval_policy = \"never\"\n",
        )
        .expect("write source");
        fs::write(&target, "approval_policy = \"on-request\"\n").expect("write target");

        write_config_snapshot(&paths, &source, &snapshot).expect("write snapshot");
        let contents = fs::read_to_string(&snapshot).expect("read snapshot");
        assert!(contents.trim().is_empty());

        apply_config_snapshot(&paths, &snapshot, &target).expect("apply snapshot");
        assert_eq!(
            fs::read_to_string(target).expect("read target"),
            "approval_policy = \"on-request\"\n"
        );
    }

    #[test]
    fn settings_without_managed_keys_keep_full_config_default() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        let settings_dir = paths.codex.join("codexswitch");
        fs::create_dir_all(&settings_dir).expect("create settings dir");
        fs::write(
            settings_dir.join("config.toml"),
            "some_future_setting = true\n",
        )
        .expect("write settings");
        let source = dir.path().join("source.toml");
        let snapshot = dir.path().join("snapshot.toml");
        fs::write(
            &source,
            "model_provider = \"custom\"\napproval_policy = \"never\"\n",
        )
        .expect("write source");

        write_config_snapshot(&paths, &source, &snapshot).expect("write snapshot");
        let contents = fs::read_to_string(snapshot).expect("read snapshot");

        assert!(contents.contains("model_provider"));
        assert!(contents.contains("approval_policy"));
    }
}