ccstats 0.3.0

Fast token and cost usage statistics CLI for Claude Code, OpenAI Codex, Cursor, and Grok
Documentation
use std::{collections::HashMap, fmt::Write};

use crate::core::Stats;
use crate::pricing::{
    PricingDb, PricingSource, pricing_source_for_model_maps, pricing_source_for_models,
};

fn needs_cache_fields(source: PricingSource, pricing_db: &PricingDb) -> bool {
    matches!(
        source,
        PricingSource::Cache | PricingSource::CacheStale | PricingSource::Mixed
    ) && pricing_db.cache_age_seconds().is_some()
}

pub(super) fn add_json(
    obj: &mut serde_json::Value,
    models: &HashMap<String, Stats>,
    pricing_db: &PricingDb,
) {
    add_source_json(
        obj,
        pricing_source_for_models(models, pricing_db),
        pricing_db,
    );
}

pub(super) fn add_model_json(obj: &mut serde_json::Value, model: &str, pricing_db: &PricingDb) {
    let source = pricing_db
        .pricing_source_for_model(model)
        .unwrap_or(PricingSource::Unknown);
    add_source_json(obj, source, pricing_db);
}

pub(super) fn add_json_for_maps<'a>(
    obj: &mut serde_json::Value,
    maps: impl IntoIterator<Item = &'a HashMap<String, Stats>>,
    pricing_db: &PricingDb,
) {
    add_source_json(
        obj,
        pricing_source_for_model_maps(maps, pricing_db),
        pricing_db,
    );
}

pub(super) fn add_source_json(
    obj: &mut serde_json::Value,
    source: PricingSource,
    pricing_db: &PricingDb,
) {
    obj["pricing_source"] = serde_json::json!(source.as_str());
    if needs_cache_fields(source, pricing_db) {
        if let Some(age) = pricing_db.cache_age_seconds() {
            obj["pricing_cache_age_seconds"] = serde_json::json!(age);
        }
        if let Some(mtime) = pricing_db.cache_modified_epoch_seconds() {
            obj["pricing_cache_mtime_epoch_seconds"] = serde_json::json!(mtime);
        }
    }
}

pub(super) fn csv_has_cache_fields(source: PricingSource, pricing_db: &PricingDb) -> bool {
    needs_cache_fields(source, pricing_db)
}

pub(super) fn append_source_csv_header(
    out: &mut String,
    source: PricingSource,
    pricing_db: &PricingDb,
) {
    out.push_str(",pricing_source");
    if csv_has_cache_fields(source, pricing_db) {
        out.push_str(",pricing_cache_age_seconds,pricing_cache_mtime_epoch_seconds");
    }
}

pub(super) fn append_csv_fields(
    out: &mut String,
    models: &HashMap<String, Stats>,
    pricing_db: &PricingDb,
    include_cache_fields: bool,
) {
    append_source_csv_fields(
        out,
        pricing_source_for_models(models, pricing_db),
        pricing_db,
        include_cache_fields,
    );
}

pub(super) fn append_model_csv_fields(
    out: &mut String,
    model: &str,
    pricing_db: &PricingDb,
    include_cache_fields: bool,
) {
    let source = pricing_db
        .pricing_source_for_model(model)
        .unwrap_or(PricingSource::Unknown);
    append_source_csv_fields(out, source, pricing_db, include_cache_fields);
}

pub(super) fn append_source_csv_fields(
    out: &mut String,
    source: PricingSource,
    pricing_db: &PricingDb,
    include_cache_fields: bool,
) {
    out.push(',');
    out.push_str(source.as_str());
    if !include_cache_fields {
        return;
    }
    if needs_cache_fields(source, pricing_db) {
        let age = pricing_db.cache_age_seconds().unwrap_or_default();
        let mtime = pricing_db
            .cache_modified_epoch_seconds()
            .unwrap_or_default();
        let _ = write!(out, ",{age},{mtime}");
    } else {
        out.push_str(",,");
    }
}

pub(super) fn note(source: PricingSource, pricing_db: &PricingDb) -> Option<String> {
    match source {
        PricingSource::Live => None,
        PricingSource::Cache => Some(format!(
            "Pricing source: cache{}.",
            cache_age_suffix(pricing_db)
        )),
        PricingSource::CacheStale => Some(format!(
            "Pricing source: stale cache{}.",
            cache_age_suffix(pricing_db)
        )),
        PricingSource::Fallback => Some("Pricing source: fallback estimates.".to_string()),
        PricingSource::Unknown => Some("Pricing source: unknown unpriced models.".to_string()),
        PricingSource::Mixed => Some(format!(
            "Pricing source: mixed{}.",
            cache_age_suffix(pricing_db)
        )),
    }
}

pub(super) fn note_for_maps<'a>(
    maps: impl IntoIterator<Item = &'a HashMap<String, Stats>>,
    pricing_db: &PricingDb,
) -> Option<String> {
    note(pricing_source_for_model_maps(maps, pricing_db), pricing_db)
}

fn cache_age_suffix(pricing_db: &PricingDb) -> String {
    let Some(age) = pricing_db.cache_age_seconds() else {
        return String::new();
    };
    let hours = age as f64 / 3600.0;
    match pricing_db.cache_modified_epoch_seconds() {
        Some(mtime) => format!(" ({hours:.1}h old, mtime {mtime})"),
        None => format!(" ({hours:.1}h old)"),
    }
}