ccstats 0.2.65

Fast token and cost usage statistics CLI for Claude Code, OpenAI Codex, Cursor, and Grok
Documentation
use std::fs;
use std::path::Path;
use std::sync::Mutex;

use ccstats::{SummaryOptions, UsageRange, UsageSource, summarize_cost};
use chrono::NaiveDate;

static ENV_LOCK: Mutex<()> = Mutex::new(());

fn write_file(path: &Path, content: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create parent dirs");
    }
    fs::write(path, content).expect("write test file");
}

#[test]
fn sdk_summarizes_codex_cost_without_running_cli() {
    let _guard = ENV_LOCK.lock().expect("env lock");
    let root = tempfile::tempdir().expect("temp dir");
    let codex_home = root.path().join("codex-home");
    let session_file = codex_home.join("sessions").join("sdk-session.jsonl");
    write_file(
        &session_file,
        r#"{"timestamp":"2026-02-06T10:00:00Z","type":"turn_context","payload":{"model":"gpt-5"}}
{"timestamp":"2026-02-06T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":10,"total_tokens":140},"last_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":10,"total_tokens":140},"model":"gpt-5"}}}
"#,
    );

    let previous_codex_home = std::env::var_os("CODEX_HOME");
    unsafe {
        std::env::set_var("CODEX_HOME", &codex_home);
    }

    let summary = summarize_cost(SummaryOptions {
        source: UsageSource::Codex,
        range: UsageRange::DateRange {
            since: Some(NaiveDate::from_ymd_opt(2026, 2, 6).unwrap()),
            until: Some(NaiveDate::from_ymd_opt(2026, 2, 6).unwrap()),
        },
        timezone: Some("UTC".to_string()),
        offline: true,
        ..SummaryOptions::default()
    })
    .expect("summarize codex");

    match previous_codex_home {
        Some(value) => unsafe {
            std::env::set_var("CODEX_HOME", value);
        },
        None => unsafe {
            std::env::remove_var("CODEX_HOME");
        },
    }

    assert_eq!(summary.source, UsageSource::Codex);
    assert_eq!(summary.source_name, "codex");
    assert_eq!(summary.valid_entries, 1);
    assert_eq!(summary.currency, "USD");
    assert_eq!(summary.tokens.input_tokens, 80);
    assert_eq!(summary.tokens.cache_read_tokens, 20);
    assert_eq!(summary.tokens.output_tokens, 20);
    assert_eq!(summary.tokens.reasoning_tokens, 10);
    assert_eq!(summary.tokens.total_tokens, 130);
    assert_eq!(summary.models.len(), 1);
    assert_eq!(summary.models[0].model, "gpt-5");
    assert!(summary.cost_usd.is_some_and(|cost| cost > 0.0));
}

#[test]
fn sdk_summarizes_grok_context_tokens_without_running_cli() {
    let _guard = ENV_LOCK.lock().expect("env lock");
    let root = tempfile::tempdir().expect("temp dir");
    let grok_home = root.path().join("grok-home");
    let session_dir = grok_home
        .join("sessions")
        .join("%2Ftmp%2Fgrok-project")
        .join("sdk-grok-session");
    write_file(
        &session_dir.join("signals.json"),
        r#"{"contextTokensUsed": 1200, "totalTokensBeforeCompaction": 300, "primaryModelId": "grok-build"}"#,
    );
    write_file(
        &session_dir.join("summary.json"),
        r#"{"updated_at": "2026-02-06T10:00:00Z", "current_model_id": "grok-build"}"#,
    );

    let previous_grok_home = std::env::var_os("GROK_HOME");
    unsafe {
        std::env::set_var("GROK_HOME", &grok_home);
    }

    let summary = summarize_cost(SummaryOptions {
        source: UsageSource::Grok,
        range: UsageRange::DateRange {
            since: Some(NaiveDate::from_ymd_opt(2026, 2, 6).unwrap()),
            until: Some(NaiveDate::from_ymd_opt(2026, 2, 6).unwrap()),
        },
        timezone: Some("UTC".to_string()),
        offline: true,
        ..SummaryOptions::default()
    })
    .expect("summarize grok");

    match previous_grok_home {
        Some(value) => unsafe {
            std::env::set_var("GROK_HOME", value);
        },
        None => unsafe {
            std::env::remove_var("GROK_HOME");
        },
    }

    assert_eq!(summary.source, UsageSource::Grok);
    assert_eq!(summary.source_name, "grok");
    assert_eq!(summary.valid_entries, 1);
    assert_eq!(summary.tokens.input_tokens, 1500);
    assert_eq!(summary.tokens.output_tokens, 0);
    assert_eq!(summary.tokens.total_tokens, 1500);
    assert_eq!(summary.models.len(), 1);
    assert_eq!(summary.models[0].model, "grok-build");
    assert!(summary.cost_usd.is_some_and(|cost| cost > 0.0));
}