terraphim_ccusage 1.20.3

Claude Code usage tracking for Terraphim AI
Documentation
//! Claude Code usage tracking and cost reporting for Terraphim AI.
//!
//! Parses Claude Code session JSONL files, aggregates token counts and
//! costs by project and session, and formats reports for the terminal or
//! robot-mode JSON output.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum CcusageError {
    #[error("No package runner found (bun, pnpm, yarn, npm, npx)")]
    NoRunner,

    #[error("Runner execution failed: {0}")]
    RunnerFailed(String),

    #[error("Failed to parse ccusage output: {0}")]
    ParseError(String),

    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),

    #[error("Timeout after {0:?}")]
    Timeout(Duration),
}

pub type Result<T> = std::result::Result<T, CcusageError>;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyUsage {
    pub date: String,
    #[serde(rename = "inputTokens", alias = "input_tokens")]
    pub input_tokens: Option<u64>,
    #[serde(rename = "outputTokens", alias = "output_tokens")]
    pub output_tokens: Option<u64>,
    #[serde(rename = "cacheCreationTokens", alias = "cache_creation_tokens")]
    pub cache_creation_tokens: Option<u64>,
    #[serde(rename = "cacheReadTokens", alias = "cache_read_tokens")]
    pub cache_read_tokens: Option<u64>,
    #[serde(rename = "cachedInputTokens", alias = "cached_input_tokens")]
    pub cached_input_tokens: Option<u64>,
    #[serde(rename = "totalTokens", alias = "total_tokens")]
    pub total_tokens: Option<u64>,
    #[serde(rename = "totalCost", alias = "total_cost")]
    pub total_cost: Option<f64>,
    #[serde(rename = "costUSD", alias = "cost_usd")]
    pub cost_usd: Option<f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyUsageReport {
    pub daily: Vec<DailyUsage>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CcusageProvider {
    Claude,
    Codex,
}

impl std::fmt::Display for CcusageProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CcusageProvider::Claude => write!(f, "claude"),
            CcusageProvider::Codex => write!(f, "codex"),
        }
    }
}

pub struct CcusageClient {
    provider: CcusageProvider,
    home_path: Option<PathBuf>,
    cache: HashMap<String, (DailyUsageReport, Instant)>,
    cache_ttl: Duration,
}

impl CcusageClient {
    pub fn new(provider: CcusageProvider) -> Self {
        Self {
            provider,
            home_path: None,
            cache: HashMap::new(),
            cache_ttl: Duration::from_secs(300),
        }
    }

    pub fn with_home_path(mut self, path: PathBuf) -> Self {
        self.home_path = Some(path);
        self
    }

    pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
        self.cache_ttl = ttl;
        self
    }

    pub fn query(&mut self, since: &str, until: Option<&str>) -> Result<DailyUsageReport> {
        let cache_key = format!("{}:{}:{}", self.provider, since, until.unwrap_or("now"));

        if let Some((report, fetched_at)) = self.cache.get(&cache_key)
            && fetched_at.elapsed() < self.cache_ttl
        {
            tracing::debug!("ccusage cache hit for {}", cache_key);
            return Ok(report.clone());
        }

        let runner = find_runner()?;

        let package = match self.provider {
            CcusageProvider::Claude => "ccusage@18.0.10",
            CcusageProvider::Codex => "@ccusage/codex@18.0.10",
        };

        let runner_cmd = if runner == "bun" {
            "bunx".to_string()
        } else {
            runner.clone()
        };

        let mut args = if runner == "bun" {
            vec![
                package.to_string(),
                "--".to_string(),
                "--since".to_string(),
                since.to_string(),
            ]
        } else {
            vec![
                "dlx".to_string(),
                package.to_string(),
                "--".to_string(),
                "--since".to_string(),
                since.to_string(),
            ]
        };

        if let Some(u) = until {
            args.push("--until".to_string());
            args.push(u.to_string());
        }

        if let Some(ref home) = self.home_path {
            args.push("--home".to_string());
            args.push(home.to_string_lossy().to_string());
        }

        tracing::info!("Running ccusage: {} {}", runner_cmd, args.join(" "));

        let output = std::process::Command::new(&runner_cmd)
            .args(&args)
            .output()
            .map_err(CcusageError::IoError)?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(CcusageError::RunnerFailed(stderr.to_string()));
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        let report = parse_ccusage_output(&stdout)?;

        self.cache
            .insert(cache_key, (report.clone(), Instant::now()));

        Ok(report)
    }

    pub fn clear_cache(&mut self) {
        self.cache.clear();
    }
}

fn find_runner() -> Result<String> {
    let runners = ["bun", "pnpm", "yarn", "npm", "npx"];
    for runner in &runners {
        if which::which(runner).is_ok() {
            return Ok(runner.to_string());
        }
    }
    Err(CcusageError::NoRunner)
}

fn parse_ccusage_output(output: &str) -> Result<DailyUsageReport> {
    let report: DailyUsageReport = serde_json::from_str(output).map_err(|e| {
        CcusageError::ParseError(format!("Failed to parse JSON: {}\nOutput: {}", e, output))
    })?;
    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_ccusage_output_camel_case() {
        let json = r#"{
            "daily": [
                {
                    "date": "2026-04-01",
                    "inputTokens": 50000,
                    "outputTokens": 25000,
                    "cacheCreationTokens": 10000,
                    "cacheReadTokens": 5000,
                    "totalTokens": 90000,
                    "totalCost": 2.50
                }
            ]
        }"#;
        let report = parse_ccusage_output(json).unwrap();
        assert_eq!(report.daily.len(), 1);
        assert_eq!(report.daily[0].date, "2026-04-01");
        assert_eq!(report.daily[0].total_tokens, Some(90000));
        assert_eq!(report.daily[0].total_cost, Some(2.50));
    }

    #[test]
    fn test_parse_ccusage_output_snake_case() {
        let json = r#"{
            "daily": [
                {
                    "date": "2026-04-01",
                    "input_tokens": 50000,
                    "output_tokens": 25000,
                    "total_tokens": 75000,
                    "cost_usd": 1.25
                }
            ]
        }"#;
        let report = parse_ccusage_output(json).unwrap();
        assert_eq!(report.daily.len(), 1);
        assert_eq!(report.daily[0].input_tokens, Some(50000));
        assert_eq!(report.daily[0].cost_usd, Some(1.25));
    }

    #[test]
    fn test_parse_empty_output() {
        let json = r#"{"daily": []}"#;
        let report = parse_ccusage_output(json).unwrap();
        assert_eq!(report.daily.len(), 0);
    }

    #[test]
    fn test_ccusage_provider_display() {
        assert_eq!(CcusageProvider::Claude.to_string(), "claude");
        assert_eq!(CcusageProvider::Codex.to_string(), "codex");
    }

    #[test]
    fn test_cache_ttl_setting() {
        let client =
            CcusageClient::new(CcusageProvider::Claude).with_cache_ttl(Duration::from_millis(1));
        assert_eq!(client.cache_ttl, Duration::from_millis(1));
    }

    #[test]
    fn test_home_path_setting() {
        let client =
            CcusageClient::new(CcusageProvider::Claude).with_home_path(PathBuf::from("/tmp/test"));
        assert_eq!(client.home_path, Some(PathBuf::from("/tmp/test")));
    }
}