claude-code-stats 0.1.0

Library and CLI for collecting Claude Code usage stats
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DataSource {
    OauthApi,
    WebApi,
    CliProbe,
}

#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageLevel {
    Normal,
    Warn,
    Danger,
    Over,
}

#[derive(Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum WidgetPayload {
    Active { data: Box<ActivePayload> },
    Error { title: String, message: String },
}

#[derive(Serialize)]
pub struct ActivePayload {
    pub five_hour: Option<UsageWindow>,
    pub seven_day: Option<UsageWindow>,
    pub seven_day_sonnet: Option<UsageWindow>,
    pub seven_day_opus: Option<UsageWindow>,
    pub extra_usage: Option<ExtraUsage>,
    pub updated_at: String,
    pub source: DataSource,
    pub cost_data: Option<CostData>,
}

#[derive(Serialize, Clone)]
pub struct UsageWindow {
    pub utilization: f64,
    pub resets_at: Option<String>,
    pub resets_in_minutes: Option<f64>,
    pub usage_level: UsageLevel,
    pub pace: Option<PaceInfo>,
}

#[derive(Serialize, Clone)]
pub struct PaceInfo {
    pub delta_percent: f64,
    pub expected_percent: f64,
    pub will_last_to_reset: bool,
    pub eta_minutes: Option<f64>,
}

#[derive(Serialize, Clone)]
pub struct ExtraUsage {
    pub is_enabled: bool,
    pub monthly_limit: Option<f64>,
    pub used_credits: Option<f64>,
    pub utilization: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostData {
    pub last_30d_cost_usd: Option<f64>,
    pub models: Vec<String>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ApiResponse {
    pub five_hour: Option<ApiWindow>,
    pub seven_day: Option<ApiWindow>,
    pub seven_day_sonnet: Option<ApiWindow>,
    pub seven_day_opus: Option<ApiWindow>,
    pub extra_usage: Option<ApiExtraUsage>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct CachedResponse {
    pub cached_at: String,
    pub response: ApiResponse,
    pub source: DataSource,
    pub cost_data: Option<CostData>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ApiWindow {
    pub utilization: Option<f64>,
    pub resets_at: Option<String>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ApiExtraUsage {
    pub is_enabled: bool,
    pub monthly_limit: Option<f64>,
    pub used_credits: Option<f64>,
    pub utilization: Option<f64>,
}

#[derive(Debug, Deserialize)]
pub struct KeychainPayload {
    #[serde(rename = "claudeAiOauth")]
    pub claude_ai_oauth: Option<OAuthData>,
}

#[derive(Debug, Deserialize)]
pub struct OAuthData {
    #[serde(rename = "accessToken")]
    pub access_token: Option<String>,
    #[serde(rename = "expiresAt")]
    pub expires_at: Option<i64>,
}

#[derive(Debug, Deserialize)]
pub struct CredentialsFile {
    #[serde(rename = "claudeAiOauth")]
    pub claude_ai_oauth: Option<OAuthData>,
}

pub fn parse_reset_time(s: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(s)
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}

pub fn usage_level(percent: f64) -> UsageLevel {
    if percent >= 100.0 {
        UsageLevel::Over
    } else if percent >= 80.0 {
        UsageLevel::Danger
    } else if percent >= 60.0 {
        UsageLevel::Warn
    } else {
        UsageLevel::Normal
    }
}

fn compute_pace(utilization: f64, resets_in_minutes: f64, window_minutes: f64) -> Option<PaceInfo> {
    if window_minutes <= 0.0 || resets_in_minutes <= 0.0 || resets_in_minutes > window_minutes {
        return None;
    }

    let elapsed_secs = (window_minutes - resets_in_minutes) * 60.0;
    let duration_secs = window_minutes * 60.0;
    let time_until_reset_secs = resets_in_minutes * 60.0;

    let actual = utilization.clamp(0.0, 100.0);
    let expected = ((elapsed_secs / duration_secs) * 100.0).clamp(0.0, 100.0);

    // CodexBar: skip when elapsed==0 but actual>0, or when expected < 3%
    if elapsed_secs == 0.0 && actual > 0.0 {
        return None;
    }
    if expected < 3.0 {
        return None;
    }

    let delta = actual - expected;

    let (will_last_to_reset, eta_minutes) = if elapsed_secs > 0.0 && actual > 0.0 {
        let rate = actual / elapsed_secs;
        if rate > 0.0 {
            let remaining = (100.0 - actual).max(0.0);
            let candidate_secs = remaining / rate;
            if candidate_secs >= time_until_reset_secs {
                (true, None)
            } else {
                (false, Some(candidate_secs / 60.0))
            }
        } else {
            (true, None)
        }
    } else if elapsed_secs > 0.0 {
        (true, None)
    } else {
        return None;
    };

    Some(PaceInfo {
        delta_percent: delta,
        expected_percent: expected,
        will_last_to_reset,
        eta_minutes,
    })
}

pub fn to_usage_window(
    api: &ApiWindow,
    now: DateTime<Utc>,
    window_minutes: Option<f64>,
) -> UsageWindow {
    let utilization = api.utilization.unwrap_or(0.0);
    let reset_dt = api.resets_at.as_deref().and_then(parse_reset_time);
    let resets_in_minutes = reset_dt.map(|reset| {
        let delta = reset.signed_duration_since(now);
        (delta.num_seconds() as f64 / 60.0).max(0.0)
    });

    let pace = match (window_minutes, resets_in_minutes) {
        (Some(wm), Some(rm)) => compute_pace(utilization, rm, wm),
        _ => None,
    };

    UsageWindow {
        utilization,
        resets_at: api.resets_at.clone(),
        resets_in_minutes,
        usage_level: usage_level(utilization),
        pace,
    }
}