codexswitch-cli 2.0.0

Ultra-fast, cross-platform profile switching for ChatGPT, Codex, and third-party providers
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Component, Path, PathBuf};

use super::{
    CONFIG_FILE_NAME, ProfileStore, assign_label, collect_profile_ids, ensure_profile_dir,
    format_action, format_list_hint, label_for_id, managed_files_contains_config,
    managed_files_for_profile, normalize_managed_files, print_output_block,
    profile_config_path_for_id, profile_path_for_id, remove_profile_dir_if_empty,
    resolve_label_target_id, update_profiles_index_entry, use_color_stderr, use_color_stdout,
};
use crate::json_response::CommandResultJson;
use crate::{
    AuthFile, PROFILE_ERR_ID_NO_MATCH, PROFILE_ERR_READ_PROFILES_DIR, Paths, is_profile_ready,
    tokens_from_api_key,
};

#[derive(Serialize, Deserialize)]
struct ExportBundle {
    version: u8,
    profiles: Vec<ExportedProfile>,
}

#[derive(Serialize, Deserialize)]
struct ExportedProfile {
    id: String,
    label: Option<String>,
    contents: serde_json::Value,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    managed_files: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    config_toml: Option<String>,
}

struct PreparedImportProfile {
    id: String,
    label: Option<String>,
    contents: Vec<u8>,
    managed_files: Vec<String>,
    config_toml: Option<Vec<u8>>,
    tokens: crate::Tokens,
}

pub(crate) fn export_profiles(
    paths: &Paths,
    label: Option<String>,
    ids: Vec<String>,
    output: PathBuf,
    json: bool,
) -> Result<(), String> {
    if output.exists() {
        return Err(format!(
            "Error: Export file already exists: {}",
            output.display()
        ));
    }

    let use_color = use_color_stdout();
    let store = ProfileStore::load(paths)?;
    let selected_ids = resolve_export_ids(paths, &store, label.as_deref(), &ids)?;
    let mut profiles = Vec::with_capacity(selected_ids.len());

    for id in selected_ids {
        let path = profile_path_for_id(&paths.profiles, &id);
        let raw = fs::read_to_string(&path)
            .map_err(|err| crate::msg2(PROFILE_ERR_READ_PROFILES_DIR, path.display(), err))?;
        let contents: serde_json::Value = serde_json::from_str(&raw)
            .map_err(|err| format!("Error: Saved profile '{}' is invalid JSON: {err}", id))?;
        let managed_files =
            managed_files_for_profile(&paths.profiles, &id, store.profiles_index.profiles.get(&id));
        let config_toml = if managed_files_contains_config(&managed_files) {
            let config_path = profile_config_path_for_id(&paths.profiles, &id);
            Some(fs::read_to_string(&config_path).map_err(|err| {
                format!(
                    "Error: Saved profile '{}' is missing config.toml at {}: {err}",
                    id,
                    config_path.display()
                )
            })?)
        } else {
            None
        };
        profiles.push(ExportedProfile {
            label: label_for_id(&store.labels, &id),
            id,
            contents,
            managed_files,
            config_toml,
        });
    }

    let bundle = ExportBundle {
        version: 1,
        profiles,
    };
    let mut bytes = serde_json::to_vec_pretty(&bundle).map_err(|err| err.to_string())?;
    bytes.push(b'\n');
    crate::common::write_atomic_private(&output, &bytes)?;
    tighten_export_permissions(&output)?;

    let count = bundle.profiles.len();
    let noun = if count == 1 { "profile" } else { "profiles" };

    if json {
        let result = CommandResultJson::success(
            "export",
            serde_json::json!({
                "path": output.display().to_string(),
                "count": count,
            }),
        );
        result.print()?;
        return Ok(());
    }

    let message = format_action(
        &format!("Exported {count} {noun} to {}", output.display()),
        use_color,
    );
    print_output_block(&message);
    Ok(())
}

pub(crate) fn import_profiles(paths: &Paths, input: PathBuf, json: bool) -> Result<(), String> {
    let use_color = use_color_stdout();
    let raw = fs::read_to_string(&input).map_err(|err| {
        format!(
            "Error: Could not read import file {}: {err}",
            input.display()
        )
    })?;
    let bundle: ExportBundle = serde_json::from_str(&raw)
        .map_err(|err| format!("Error: Import file is invalid JSON: {err}"))?;
    if bundle.version != 1 {
        return Err(format!(
            "Error: Import file version {} is not supported.",
            bundle.version
        ));
    }

    let mut store = ProfileStore::load(paths)?;
    let existing_ids = collect_profile_ids(&paths.profiles)?;
    let mut staged_labels = store.labels.clone();
    let mut seen_ids = HashSet::new();
    let mut prepared = Vec::with_capacity(bundle.profiles.len());
    for profile in bundle.profiles {
        validate_import_profile_id(&profile.id)?;
        if !seen_ids.insert(profile.id.clone()) {
            return Err(format!(
                "Error: Import bundle contains duplicate profile id '{}'.",
                profile.id
            ));
        }
        if existing_ids.contains(&profile.id) {
            return Err(format!("Error: Profile '{}' already exists.", profile.id));
        }
        if let Some(label) = profile.label.as_deref() {
            assign_label(&mut staged_labels, label, &profile.id)?;
        }
        prepared.push(prepare_import_profile(profile)?);
    }

    let mut written_ids = Vec::with_capacity(prepared.len());
    for profile in &prepared {
        if let Err(err) = ensure_profile_dir(&paths.profiles, &profile.id) {
            cleanup_imported_profiles(paths, &written_ids);
            return Err(err);
        }
        written_ids.push(profile.id.clone());
        let path = profile_path_for_id(&paths.profiles, &profile.id);
        if let Err(err) = crate::common::write_atomic_private(&path, &profile.contents) {
            cleanup_imported_profiles(paths, &written_ids);
            return Err(err);
        }
        if let Some(config_toml) = profile.config_toml.as_deref() {
            let config_path = profile_config_path_for_id(&paths.profiles, &profile.id);
            if let Err(err) = crate::common::write_atomic_private(&config_path, config_toml) {
                cleanup_imported_profiles(paths, &written_ids);
                return Err(err);
            }
        }
    }

    for profile in &prepared {
        if let Some(label) = profile.label.as_deref() {
            assign_label(&mut store.labels, label, &profile.id)?;
        }
        update_profiles_index_entry(
            &mut store.profiles_index,
            &profile.id,
            Some(&profile.tokens),
            profile.label.clone(),
            Some(profile.managed_files.clone()),
        );
    }
    if let Err(err) = store.save(paths) {
        cleanup_imported_profiles(paths, &written_ids);
        return Err(err);
    }

    let count = prepared.len();
    let noun = if count == 1 { "profile" } else { "profiles" };

    if json {
        let imported: Vec<serde_json::Value> = prepared
            .iter()
            .map(|p| {
                serde_json::json!({
                    "id": p.id,
                    "label": p.label,
                    "managed_files": p.managed_files,
                })
            })
            .collect();
        let result = CommandResultJson::success(
            "import",
            serde_json::json!({
                "count": count,
                "profiles": imported,
            }),
        );
        result.print()?;
        return Ok(());
    }

    let message = format_action(
        &format!("Imported {count} {noun} from {}", input.display()),
        use_color,
    );
    print_output_block(&message);
    Ok(())
}

fn resolve_export_ids(
    paths: &Paths,
    store: &ProfileStore,
    label: Option<&str>,
    ids: &[String],
) -> Result<Vec<String>, String> {
    if let Some(label) = label {
        return Ok(vec![resolve_label_target_id(store, Some(label), None)?]);
    }

    let available_ids = collect_profile_ids(&paths.profiles)?;
    if ids.is_empty() {
        let mut all: Vec<String> = available_ids.into_iter().collect();
        all.sort();
        return Ok(all);
    }

    let mut selected = Vec::new();
    let mut seen = HashSet::new();
    for id in ids {
        if !available_ids.contains(id) {
            return Err(crate::msg2(
                PROFILE_ERR_ID_NO_MATCH,
                id,
                format_list_hint(use_color_stderr()),
            ));
        }
        if seen.insert(id.clone()) {
            selected.push(id.clone());
        }
    }
    Ok(selected)
}

fn prepare_import_profile(profile: ExportedProfile) -> Result<PreparedImportProfile, String> {
    let ExportedProfile {
        id,
        label,
        contents,
        managed_files,
        config_toml,
    } = profile;
    let mut managed_files = normalize_managed_files(managed_files);
    if config_toml.is_some() && !managed_files_contains_config(&managed_files) {
        managed_files.push(CONFIG_FILE_NAME.to_string());
        managed_files = normalize_managed_files(managed_files);
    }
    if managed_files_contains_config(&managed_files) && config_toml.is_none() {
        return Err(format!(
            "Error: Exported profile '{}' includes config.toml but no config_toml field.",
            id
        ));
    }
    let config_toml = config_toml.map(|contents| {
        let mut bytes = contents.into_bytes();
        if !bytes.ends_with(b"\n") {
            bytes.push(b'\n');
        }
        bytes
    });
    let mut bytes = serde_json::to_vec_pretty(&contents).map_err(|err| {
        format!(
            "Error: Exported profile '{}' could not be serialized: {err}",
            id
        )
    })?;
    bytes.push(b'\n');

    let auth: AuthFile = serde_json::from_value(contents)
        .map_err(|err| format!("Error: Exported profile '{}' is invalid JSON: {err}", id))?;
    let tokens = if let Some(tokens) = auth.tokens {
        tokens
    } else if let Some(api_key) = auth.openai_api_key.as_deref() {
        tokens_from_api_key(api_key)
    } else {
        return Err(format!(
            "Error: Exported profile '{}' is missing tokens or API key.",
            id
        ));
    };
    if !is_profile_ready(&tokens) {
        return Err(format!("Error: Exported profile '{}' is incomplete.", id));
    }

    Ok(PreparedImportProfile {
        id,
        label,
        contents: bytes,
        managed_files,
        config_toml,
        tokens,
    })
}

fn validate_import_profile_id(id: &str) -> Result<(), String> {
    let mut components = Path::new(id).components();
    if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
        return Err(format!("Error: Imported profile id '{}' is not safe.", id));
    }
    if matches!(id, "profiles" | "update") {
        return Err(format!("Error: Imported profile id '{}' is reserved.", id));
    }
    Ok(())
}

fn cleanup_imported_profiles(paths: &Paths, ids: &[String]) {
    for id in ids {
        let _ = fs::remove_file(profile_path_for_id(&paths.profiles, id));
        let _ = fs::remove_file(profile_config_path_for_id(&paths.profiles, id));
        let _ = remove_profile_dir_if_empty(&paths.profiles, id);
    }
}

fn tighten_export_permissions(path: &Path) -> Result<(), String> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let permissions = fs::Permissions::from_mode(0o600);
        fs::set_permissions(path, permissions).map_err(|err| {
            format!(
                "Error: Could not secure export file {}: {err}",
                path.display()
            )
        })?;
    }
    Ok(())
}