clauth 0.3.0

Simple Claude Code account switcher and usage monitor
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::lock::with_state_lock;
use crate::profile::{
    AppConfig, ClaudeCredentials, Profile, atomic_write, home_dir, profile_dir, save_profile,
};

fn claude_credentials_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude").join(".credentials.json"))
}

fn claude_settings_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude").join("settings.json"))
}

pub(crate) fn read_claude_credentials() -> Result<Option<ClaudeCredentials>> {
    let path = claude_credentials_path()?;
    if !path.exists() {
        return Ok(None);
    }
    let content = std::fs::read_to_string(&path).context("Failed to read .credentials.json")?;
    serde_json::from_str(&content)
        .context("Failed to parse .credentials.json")
        .map(Some)
}

#[cfg(unix)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    std::os::unix::fs::symlink(target, link).context("Failed to create credential symlink")
}

#[cfg(windows)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    match std::os::windows::fs::symlink_file(target, link) {
        Ok(()) => Ok(()),
        Err(_) => std::fs::copy(target, link)
            .map(|_| ())
            .context("Failed to copy credentials"),
    }
}

#[cfg(not(any(unix, windows)))]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    std::fs::copy(target, link)
        .map(|_| ())
        .context("Failed to copy credentials")
}

/// Symlinks `~/.claude/.credentials.json` → profile's `credentials.json`;
/// copies on Windows without symlink privilege.
pub(crate) fn link_profile_credentials(name: &str) -> Result<()> {
    with_state_lock(|| {
        let link = claude_credentials_path()?;

        if link.symlink_metadata().is_ok() {
            std::fs::remove_file(&link).context("Failed to remove old .credentials.json")?;
        }

        let target = profile_dir(name)?.join("credentials.json");
        if target.exists() {
            if let Some(parent) = link.parent() {
                std::fs::create_dir_all(parent)?;
            }
            create_symlink(&target, &link)?;
        }

        Ok(())
    })
}

pub(crate) fn clear_claude_credentials() -> Result<()> {
    with_state_lock(|| {
        let link = claude_credentials_path()?;
        if link.symlink_metadata().is_ok() {
            std::fs::remove_file(&link).context("Failed to remove .credentials.json")?;
        }
        Ok(())
    })
}

pub(crate) struct ClaudeEndpoint {
    pub(crate) base_url: Option<String>,
    pub(crate) api_key: Option<String>,
}

pub(crate) fn read_claude_endpoint_config() -> Result<ClaudeEndpoint> {
    let path = claude_settings_path()?;
    if !path.exists() {
        return Ok(ClaudeEndpoint {
            base_url: None,
            api_key: None,
        });
    }
    let content = std::fs::read_to_string(&path).context("Failed to read settings.json")?;
    let settings: serde_json::Value =
        serde_json::from_str(&content).context("Failed to parse settings.json")?;
    Ok(ClaudeEndpoint {
        base_url: settings["env"]["ANTHROPIC_BASE_URL"]
            .as_str()
            .map(str::to_owned),
        api_key: settings["env"]["ANTHROPIC_AUTH_TOKEN"]
            .as_str()
            .map(str::to_owned),
    })
}

/// Patches `settings.json`'s `env` block with ANTHROPIC_BASE_URL,
/// ANTHROPIC_AUTH_TOKEN, and the profile's `env` map. Keys in `prev_env_keys`
/// that the new profile doesn't carry are removed first so stale entries from
/// the previously active profile don't linger. Every other field is untouched.
pub(crate) fn apply_profile_to_claude_settings(
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<()> {
    with_state_lock(|| apply_profile_to_claude_settings_inner(profile, prev_env_keys))
}

fn apply_profile_to_claude_settings_inner(
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<()> {
    let path = claude_settings_path()?;

    let has_anything = profile.base_url.is_some()
        || profile.api_key.is_some()
        || !profile.env.is_empty()
        || !prev_env_keys.is_empty();
    if !has_anything && !path.exists() {
        return Ok(());
    }

    let content = build_claude_settings_json(&path, profile, prev_env_keys)?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    atomic_write(&path, content).context("Failed to write settings.json")
}

/// Merges `base_path`'s settings.json (or `{}` when missing) with the profile's
/// endpoint keys and env overlay. `prev_env_keys` lists env keys to strip
/// before applying the new profile — used by the switch path to clear the
/// previously active profile's custom env. `start` passes `&[]` so existing
/// keys in the file stay untouched.
pub(crate) fn build_claude_settings_json(
    base_path: &Path,
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<String> {
    let mut settings: serde_json::Value = if base_path.exists() {
        let content = std::fs::read_to_string(base_path).context("Failed to read settings.json")?;
        serde_json::from_str(&content).context("Failed to parse settings.json")?
    } else {
        serde_json::json!({})
    };

    if settings.get("env").is_none() {
        settings["env"] = serde_json::json!({});
    }

    let env = settings["env"]
        .as_object_mut()
        .context("settings.json `env` is not an object")?;

    for key in prev_env_keys {
        if !profile.env.contains_key(key) {
            env.remove(key);
        }
    }

    match profile.base_url.as_deref() {
        Some(url) => {
            env.insert("ANTHROPIC_BASE_URL".into(), url.into());
        }
        None => {
            env.remove("ANTHROPIC_BASE_URL");
        }
    }
    match profile.api_key.as_deref() {
        Some(key) => {
            env.insert("ANTHROPIC_AUTH_TOKEN".into(), key.into());
        }
        None => {
            env.remove("ANTHROPIC_AUTH_TOKEN");
        }
    }

    // Apply profile env last so an explicit ANTHROPIC_* entry in the profile
    // env map wins over the dedicated base_url / api_key fields.
    for (k, v) in &profile.env {
        env.insert(k.clone(), v.clone().into());
    }

    serde_json::to_string_pretty(&settings).context("Failed to serialize settings.json")
}

/// Reads the live .credentials.json and saves it to the active profile.
pub(crate) fn snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
    with_state_lock(|| {
        let Some(active) = config.state.active_profile.clone() else {
            return Ok(());
        };
        let credentials = read_claude_credentials()?;
        if let Some(profile) = config.find_mut(&active) {
            profile.credentials = credentials;
            save_profile(profile)?;
        }
        Ok(())
    })
}

/// Returns true when both sides carry an OAuth block and either the access
/// token or refresh token differs. Missing data on either side returns false
/// — the caller's normal snapshot/skip path is safer than guessing.
pub(crate) fn credentials_diverged(
    stored: Option<&ClaudeCredentials>,
    live: Option<&ClaudeCredentials>,
) -> bool {
    let Some(stored) = stored.and_then(|c| c.claude_ai_oauth.as_ref()) else {
        return false;
    };
    let Some(live) = live.and_then(|c| c.claude_ai_oauth.as_ref()) else {
        return false;
    };
    stored.access_token != live.access_token || stored.refresh_token != live.refresh_token
}

/// Replaces the symlink at `~/.claude/.credentials.json` with a regular file
/// containing the same bytes. No-op when the path is already a regular file
/// or doesn't exist. Called when the user disowns the active profile so
/// subsequent Claude Code writes don't bleed into that profile's storage
/// through the symlink.
pub(crate) fn detach_credentials_link() -> Result<()> {
    with_state_lock(|| {
        let path = claude_credentials_path()?;
        let Ok(meta) = path.symlink_metadata() else {
            return Ok(());
        };
        if !meta.file_type().is_symlink() {
            return Ok(());
        }
        let content =
            std::fs::read(&path).context("Failed to read .credentials.json before detach")?;
        std::fs::remove_file(&path).context("Failed to remove .credentials.json symlink")?;
        atomic_write(&path, content).context("Failed to write detached .credentials.json")?;
        Ok(())
    })
}

#[cfg(test)]
#[path = "../tests/inline/claude.rs"]
mod tests;