clauth 0.2.0

A simple Claude Code account switcher - swap OAuth and API profiles in an instant
use anyhow::{Context, Result, bail};
use inquire::{Confirm, InquireError, Text};

use crate::claude::{
    ClaudeEndpoint, apply_profile_to_claude_settings, clear_claude_credentials,
    link_profile_credentials, read_claude_credentials, read_claude_endpoint_config,
    snapshot_active_credentials,
};
use crate::lock::with_state_lock;
use crate::profile::{
    AppConfig, ClaudeCredentials, Profile, profile_dir, save_app_state, save_profile,
};

// ── Prompts ───────────────────────────────────────────────────────────────────

pub(crate) fn prompt_optional(label: &str, current: Option<&str>) -> Result<Option<String>> {
    let value = Text::new(label)
        .with_default(current.unwrap_or(""))
        .with_help_message("Leave empty to unset")
        .prompt()?;
    Ok((!value.trim().is_empty()).then_some(value))
}

pub(crate) fn prompt_profile_name(existing: &[&str], exclude: Option<&str>) -> Result<String> {
    let name = Text::new("Profile name:").prompt()?.trim().to_string();
    if name.is_empty() {
        bail!("Name cannot be empty.");
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
        || name.starts_with('.')
    {
        bail!(
            "Name must contain only letters, digits, '-', '_', or '.', and cannot start with '.'."
        );
    }
    if existing.iter().any(|&n| n == name && Some(n) != exclude) {
        bail!("A profile named '{name}' already exists.");
    }
    Ok(name)
}

pub(crate) fn is_cancelled(error: &anyhow::Error) -> bool {
    matches!(
        error.downcast_ref::<InquireError>(),
        Some(InquireError::OperationCanceled | InquireError::OperationInterrupted),
    )
}

// ── Submenu actions ───────────────────────────────────────────────────────────

pub(crate) fn switch_profile(config: &mut AppConfig, name: &str) -> Result<()> {
    with_state_lock(|| {
        if config.is_active(name) {
            return Ok(());
        }

        snapshot_active_credentials(config)?;

        let prev_env_keys: Vec<String> = config
            .state
            .active_profile
            .as_deref()
            .and_then(|n| config.find(n))
            .map(|p| p.env.keys().cloned().collect())
            .unwrap_or_default();

        link_profile_credentials(name)?;
        let profile = config.find(name).context("Profile not found")?;
        apply_profile_to_claude_settings(profile, &prev_env_keys)?;
        config.state.active_profile = Some(name.to_string());
        save_app_state(&config.state)
    })
}

pub(crate) fn edit_profile(config: &mut AppConfig, name: &str) -> Result<()> {
    let (current_url, current_key) = {
        let profile = config.find(name).context("Profile not found")?;
        (profile.base_url.clone(), profile.api_key.clone())
    };

    // Prompts run outside the lock — holding the state lock while waiting on
    // user input would block other clauth processes indefinitely.
    let base_url = prompt_optional("Base URL:", current_url.as_deref())?;
    let api_key = prompt_optional("API key:", current_key.as_deref())?;

    with_state_lock(|| {
        let profile = config.find_mut(name).context("Profile not found")?;
        profile.base_url = base_url;
        profile.api_key = api_key;
        save_profile(profile)?;

        if config.is_active(name) {
            let profile = config.find(name).context("Profile not found")?;
            let prev_env_keys: Vec<String> = profile.env.keys().cloned().collect();
            apply_profile_to_claude_settings(profile, &prev_env_keys)?;
        }
        Ok(())
    })
}

/// Returns true if the profile was renamed (submenu should exit to refresh list).
pub(crate) fn rename_profile(config: &mut AppConfig, old: &str) -> Result<bool> {
    let new = match prompt_profile_name(&config.names(), Some(old)) {
        Ok(n) => n,
        Err(e) if is_cancelled(&e) => return Ok(false),
        Err(e) => return Err(e),
    };

    with_state_lock(|| {
        let old_dir = profile_dir(old)?;
        if old_dir.exists() {
            std::fs::rename(&old_dir, profile_dir(&new)?)
                .with_context(|| format!("Failed to rename profile directory to '{new}'"))?;
        }

        if let Some(profile) = config.find_mut(old) {
            profile.name = new.clone();
        }
        if let Some(slot) = config.state.profiles.iter_mut().find(|n| n.as_str() == old) {
            *slot = new.clone();
        }
        if let Some(slot) = config
            .state
            .fallback_chain
            .iter_mut()
            .find(|n| n.as_str() == old)
        {
            *slot = new.clone();
        }
        let was_active = config.is_active(old);
        if was_active {
            config.state.active_profile = Some(new.clone());
        }

        save_app_state(&config.state)?;

        if was_active {
            link_profile_credentials(&new)?;
        }
        Ok(true)
    })
}

/// Returns true if the profile was deleted (submenu should exit).
pub(crate) fn delete_profile(config: &mut AppConfig, name: &str) -> Result<bool> {
    let confirmed = match Confirm::new(&format!("Delete '{name}'?"))
        .with_default(false)
        .prompt()
    {
        Ok(c) => c,
        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
            return Ok(false);
        }
        Err(e) => return Err(e.into()),
    };
    if !confirmed {
        return Ok(false);
    }

    with_state_lock(|| {
        let was_active = config.is_active(name);
        let dir = profile_dir(name)?;

        config.remove(name);
        save_app_state(&config.state)?;

        if dir.exists() {
            std::fs::remove_dir_all(&dir)
                .with_context(|| format!("Failed to delete profile directory for '{name}'"))?;
        }
        if was_active {
            clear_claude_credentials()?;
        }
        Ok(true)
    })
}

// ── Main-menu actions ─────────────────────────────────────────────────────────

pub(crate) fn create_blank_profile(config: &mut AppConfig) -> Result<()> {
    let name = prompt_profile_name(&config.names(), None)?;
    let base_url = prompt_optional("Base URL:", None)?;
    let api_key = if base_url.is_some() {
        prompt_optional("API key:", None)?
    } else {
        None
    };

    with_state_lock(|| {
        let profile = Profile::new(name, base_url, api_key);
        save_profile(&profile)?;
        config.add(profile);
        save_app_state(&config.state)
    })
}

/// Finds an existing profile whose stored OAuth tokens match `live`. The
/// usual trigger is capturing while a profile is active — the live file is
/// symlinked to that profile's storage, so the capture would duplicate the
/// same identity into a second slot.
fn find_matching_oauth_profile<'a>(
    config: &'a AppConfig,
    live: Option<&ClaudeCredentials>,
) -> Option<&'a str> {
    let live_oauth = live?.claude_ai_oauth.as_ref()?;
    config.profiles.iter().find_map(|p| {
        let stored = p.credentials.as_ref()?.claude_ai_oauth.as_ref()?;
        (stored.access_token == live_oauth.access_token
            && stored.refresh_token == live_oauth.refresh_token)
            .then_some(p.name.as_str())
    })
}

pub(crate) fn capture_current_profile(config: &mut AppConfig) -> Result<()> {
    let credentials = read_claude_credentials()?;
    let ClaudeEndpoint { base_url, api_key } = read_claude_endpoint_config()?;

    if let Some(matching) = find_matching_oauth_profile(config, credentials.as_ref()) {
        let proceed = match Confirm::new(&format!(
            "These credentials already belong to profile '{matching}'. Capture anyway?"
        ))
        .with_default(false)
        .prompt()
        {
            Ok(b) => b,
            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => false,
            Err(e) => return Err(e.into()),
        };
        if !proceed {
            return Ok(());
        }
    }

    let name = prompt_profile_name(&config.names(), None)?;

    with_state_lock(|| {
        let mut profile = Profile::new(name.clone(), base_url, api_key);
        profile.credentials = credentials;
        save_profile(&profile)?;
        config.add(profile);

        if config.state.active_profile.is_none() {
            link_profile_credentials(&name)?;
            config.state.active_profile = Some(name);
        }
        save_app_state(&config.state)
    })
}