clauth 0.2.0

A simple Claude Code account switcher - swap OAuth and API profiles in an instant
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use crate::lock::with_state_lock;
use crate::usage::{FetchStatus, UsageInfo};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClaudeCredentials {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) claude_ai_oauth: Option<OAuthToken>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OAuthToken {
    pub(crate) access_token: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) refresh_token: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) expires_at: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) scopes: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) subscription_type: Option<String>,
}

#[derive(Debug, Clone)]
pub(crate) struct Profile {
    pub(crate) name: String,
    pub(crate) base_url: Option<String>,
    pub(crate) api_key: Option<String>,
    /// When true, clauth fires a 1-token Haiku ping at startup if this
    /// profile has no active 5h window. Mirrors what Claude Code does on
    /// launch; opt-in because it consumes a (tiny) amount of usage.
    pub(crate) kick_timer: bool,
    /// Extra env vars to apply to `~/.claude/settings.json`'s `env` block
    /// while this profile is active. Cleared on switch to another profile.
    pub(crate) env: BTreeMap<String, String>,
    /// 5-hour utilization percentage at/above which the auto-switch system
    /// will move off this profile. Only takes effect while the profile is a
    /// member of `AppState::fallback_chain`. None = use default (95%).
    pub(crate) fallback_threshold: Option<f64>,
    pub(crate) credentials: Option<ClaudeCredentials>,
    pub(crate) usage: Option<UsageInfo>,
    /// Outcome of the most recent usage fetch — drives the account-name
    /// underline so users can spot stale or missing data at a glance. None
    /// before the first fetch attempt.
    pub(crate) fetch_status: Option<FetchStatus>,
}

impl Profile {
    pub(crate) fn new(name: String, base_url: Option<String>, api_key: Option<String>) -> Self {
        Self {
            name,
            base_url,
            api_key,
            kick_timer: false,
            env: BTreeMap::new(),
            fallback_threshold: None,
            credentials: None,
            usage: None,
            fetch_status: None,
        }
    }
}

/// Stored at ~/.clauth/profiles.toml — ordering and active marker only.
/// Credentials and endpoint config live in per-profile subdirectories.
#[derive(Debug, Serialize, Deserialize, Default)]
pub(crate) struct AppState {
    pub(crate) active_profile: Option<String>,
    pub(crate) profiles: Vec<String>,
    /// Epoch-ms of the last successful timer kick per profile. Used to skip
    /// rekicking a profile whose previous kick should still be inside its
    /// 5-hour window, so a silently-failed usage fetch doesn't cause us to
    /// re-spend the kick on every launch.
    #[serde(default)]
    pub(crate) last_kick_at: HashMap<String, u64>,
    /// Ordered list of profile names participating in the auto-switch chain.
    /// When the active profile's 5h utilization crosses its
    /// `fallback_threshold`, clauth walks this list (starting after the
    /// active entry, wrapping around) and switches to the first member whose
    /// own threshold has not been crossed.
    #[serde(default)]
    pub(crate) fallback_chain: Vec<String>,
}

pub(crate) struct AppConfig {
    pub(crate) state: AppState,
    pub(crate) profiles: Vec<Profile>,
}

impl AppConfig {
    pub(crate) fn is_active(&self, name: &str) -> bool {
        self.state.active_profile.as_deref() == Some(name)
    }

    pub(crate) fn find(&self, name: &str) -> Option<&Profile> {
        self.profiles.iter().find(|p| p.name == name)
    }

    pub(crate) fn find_mut(&mut self, name: &str) -> Option<&mut Profile> {
        self.profiles.iter_mut().find(|p| p.name == name)
    }

    pub(crate) fn names(&self) -> Vec<&str> {
        self.profiles.iter().map(|p| p.name.as_str()).collect()
    }

    pub(crate) fn add(&mut self, profile: Profile) {
        self.state.profiles.push(profile.name.clone());
        self.profiles.push(profile);
    }

    pub(crate) fn remove(&mut self, name: &str) {
        self.profiles.retain(|p| p.name != name);
        self.state.profiles.retain(|n| n != name);
        self.state.fallback_chain.retain(|n| n != name);
        if self.is_active(name) {
            self.state.active_profile = None;
        }
    }
}

/// On-disk format for ~/.clauth/profiles/<name>/config.toml
#[derive(Debug, Serialize, Deserialize, Default)]
struct ProfileConfig {
    base_url: Option<String>,
    api_key: Option<String>,
    #[serde(default)]
    kick_timer: bool,
    #[serde(default)]
    env: BTreeMap<String, String>,
    #[serde(default)]
    fallback_threshold: Option<f64>,
}

/// Hand-rolled TOML writer that keeps every option visible — set values are
/// uncommented, unset ones stay as commented examples so users can discover
/// what's available without consulting the README.
fn render_config_toml(profile: &Profile) -> String {
    fn toml_str(s: &str) -> String {
        toml::Value::String(s.to_string()).to_string()
    }

    let mut out = String::from("# clauth profile configuration\n\n");

    out.push_str("# Base URL for an API-endpoint profile. Leave commented for an OAuth\n");
    out.push_str("# (Pro / Max / Team / Enterprise) profile.\n");
    match profile.base_url.as_deref() {
        Some(v) => out.push_str(&format!("base_url = {}\n", toml_str(v))),
        None => out.push_str("# base_url = \"https://api.anthropic.com\"\n"),
    }
    out.push('\n');

    out.push_str("# API key for the endpoint. Only used when base_url is set.\n");
    match profile.api_key.as_deref() {
        Some(v) => out.push_str(&format!("api_key = {}\n", toml_str(v))),
        None => out.push_str("# api_key = \"sk-ant-...\"\n"),
    }
    out.push('\n');

    out.push_str("# Fire a 1-token Haiku ping at startup to start the 5-hour usage\n");
    out.push_str("# window when this profile has no running window. Costs ~0.001¢ per\n");
    out.push_str("# kick. OAuth profiles only.\n");
    if profile.kick_timer {
        out.push_str("kick_timer = true\n");
    } else {
        out.push_str("# kick_timer = true\n");
    }
    out.push('\n');

    out.push_str("# 5-hour utilization percentage at/above which clauth will auto-switch\n");
    out.push_str("# off this profile, provided the profile is also a member of the\n");
    out.push_str("# fallback chain configured in ~/.clauth/profiles.toml. Range 0..=100.\n");
    match profile.fallback_threshold {
        Some(v) => out.push_str(&format!("fallback_threshold = {v}\n")),
        None => out.push_str("# fallback_threshold = 95.0\n"),
    }
    out.push('\n');

    out.push_str("# Extra env vars merged into ~/.claude/settings.json's env block while\n");
    out.push_str("# this profile is active. Cleared on switch to another profile.\n");
    if profile.env.is_empty() {
        out.push_str("# [env]\n");
        out.push_str("# HTTP_PROXY = \"http://localhost:8080\"\n");
    } else {
        out.push_str("[env]\n");
        for (k, v) in &profile.env {
            out.push_str(&format!("{k} = {}\n", toml_str(v)));
        }
    }

    out
}

// ── Path helpers ──────────────────────────────────────────────────────────────

pub(crate) fn home_dir() -> Result<PathBuf> {
    dirs::home_dir().context("Cannot determine home directory")
}

pub(crate) fn clauth_dir() -> Result<PathBuf> {
    Ok(home_dir()?.join(".clauth"))
}

/// Write `content` to `path` via tempfile + rename so concurrent readers in
/// other instances see either the old file or the new one — never a partial
/// write. Falls back to a plain write when rename across the temp file fails
/// (e.g. exotic filesystems).
pub(crate) fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
    let dir = path.parent().unwrap_or_else(|| Path::new("."));
    if !dir.exists() {
        std::fs::create_dir_all(dir)?;
    }
    let file_name = path
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "file".to_string());
    let tmp = dir.join(format!(".{file_name}.tmp.{}", std::process::id()));
    std::fs::write(&tmp, content)?;
    match std::fs::rename(&tmp, path) {
        Ok(()) => Ok(()),
        Err(e) => {
            let _ = std::fs::remove_file(&tmp);
            Err(e)
        }
    }
}

pub(crate) fn app_state_mtime() -> Option<SystemTime> {
    let path = app_state_path().ok()?;
    std::fs::metadata(&path).ok()?.modified().ok()
}

fn profiles_root() -> Result<PathBuf> {
    Ok(clauth_dir()?.join("profiles"))
}

fn app_state_path() -> Result<PathBuf> {
    Ok(clauth_dir()?.join("profiles.toml"))
}

pub(crate) fn profile_dir(name: &str) -> Result<PathBuf> {
    Ok(profiles_root()?.join(name))
}

fn profile_config_path(name: &str) -> Result<PathBuf> {
    Ok(profile_dir(name)?.join("config.toml"))
}

fn profile_credentials_path(name: &str) -> Result<PathBuf> {
    Ok(profile_dir(name)?.join("credentials.json"))
}

// ── Persistence ───────────────────────────────────────────────────────────────

fn load_app_state() -> Result<AppState> {
    let path = app_state_path()?;
    if !path.exists() {
        return Ok(AppState::default());
    }
    let content = std::fs::read_to_string(&path).context("Failed to read profiles.toml")?;
    toml::from_str(&content).context("Failed to parse profiles.toml")
}

pub(crate) fn save_app_state(state: &AppState) -> Result<()> {
    with_state_lock(|| {
        std::fs::create_dir_all(clauth_dir()?)?;
        atomic_write(&app_state_path()?, toml::to_string_pretty(state)?)
            .context("Failed to write profiles.toml")
    })
}

fn load_profile(name: &str) -> Result<Profile> {
    let config_path = profile_config_path(name)?;
    let raw_config = match std::fs::read_to_string(&config_path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(e).with_context(|| format!("Failed to read {name}/config.toml")),
    };
    let config: ProfileConfig = if raw_config.trim().is_empty() {
        ProfileConfig::default()
    } else {
        toml::from_str(&raw_config)
            .with_context(|| format!("Failed to parse {name}/config.toml"))?
    };

    let cred_path = profile_credentials_path(name)?;
    let credentials = if cred_path.exists() {
        let content = std::fs::read_to_string(&cred_path)
            .with_context(|| format!("Failed to read {name}/credentials.json"))?;
        Some(
            serde_json::from_str(&content)
                .with_context(|| format!("Failed to parse {name}/credentials.json"))?,
        )
    } else {
        None
    };

    let profile = Profile {
        name: name.to_string(),
        base_url: config.base_url,
        api_key: config.api_key,
        kick_timer: config.kick_timer,
        env: config.env,
        fallback_threshold: config.fallback_threshold,
        credentials,
        usage: None,
        fetch_status: None,
    };

    // Keep config.toml in sync with the canonical template: missing options
    // get added as comments, values already set are preserved in place.
    let rendered = render_config_toml(&profile);
    if raw_config != rendered {
        let _ = with_state_lock(|| {
            let _ = atomic_write(&config_path, &rendered);
            Ok(())
        });
    }

    Ok(profile)
}

pub(crate) fn save_profile(profile: &Profile) -> Result<()> {
    with_state_lock(|| {
        std::fs::create_dir_all(profile_dir(&profile.name)?)?;

        atomic_write(
            &profile_config_path(&profile.name)?,
            render_config_toml(profile),
        )
        .context("Failed to write config.toml")?;

        let cred_path = profile_credentials_path(&profile.name)?;
        match &profile.credentials {
            Some(creds) => atomic_write(&cred_path, serde_json::to_string_pretty(creds)?)
                .context("Failed to write credentials.json")?,
            None if cred_path.exists() => {
                std::fs::remove_file(&cred_path).context("Failed to remove credentials.json")?
            }
            None => {}
        }

        Ok(())
    })
}

pub(crate) fn load_config() -> Result<AppConfig> {
    std::fs::create_dir_all(profiles_root()?)?;
    let state = load_app_state()?;
    let profiles = state
        .profiles
        .iter()
        .map(|n| load_profile(n))
        .collect::<Result<Vec<_>>>()?;
    Ok(AppConfig { state, profiles })
}