use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelLastUsage {
#[serde(rename = "costUSD")]
pub cost_usd: f64,
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
#[serde(default)]
pub web_search_requests: u64,
}
impl ModelLastUsage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
}
#[derive(Debug, Clone)]
pub struct ProjectLastUsage {
pub path: String,
pub name: String,
pub last_cost: f64,
pub model_usage: HashMap<String, ModelLastUsage>,
}
#[derive(Debug, Clone, Default)]
pub struct ClaudeGlobalStats {
pub projects: Vec<ProjectLastUsage>,
pub total_last_cost: f64,
}
#[derive(Debug, Deserialize)]
struct RawProject {
#[serde(default, rename = "lastModelUsage")]
last_model_usage: HashMap<String, ModelLastUsage>,
#[serde(default, rename = "lastCost")]
last_cost: f64,
}
#[derive(Debug, Deserialize)]
struct RawClaudeJson {
#[serde(default)]
projects: HashMap<String, RawProject>,
}
pub fn parse_claude_global(home: &Path) -> Option<ClaudeGlobalStats> {
let path = home.join(".claude.json");
if !path.exists() {
return None;
}
let data = std::fs::read(&path).ok()?;
let raw: RawClaudeJson = serde_json::from_slice(&data).ok()?;
let mut projects: Vec<ProjectLastUsage> = raw
.projects
.into_iter()
.filter_map(|(raw_path, project)| {
if project.last_model_usage.is_empty() && project.last_cost == 0.0 {
return None;
}
let name = Path::new(&raw_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&raw_path)
.to_string();
Some(ProjectLastUsage {
path: raw_path,
name,
last_cost: project.last_cost,
model_usage: project.last_model_usage,
})
})
.collect();
projects.sort_by(|a, b| {
b.last_cost
.partial_cmp(&a.last_cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_last_cost = projects.iter().map(|p| p.last_cost).sum();
Some(ClaudeGlobalStats {
projects,
total_last_cost,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_claude_json(dir: &TempDir, content: &str) {
let path = dir.path().join(".claude.json");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_parse_missing_file() {
let dir = TempDir::new().unwrap();
assert!(parse_claude_global(dir.path()).is_none());
}
#[test]
fn test_parse_valid() {
let dir = TempDir::new().unwrap();
write_claude_json(
&dir,
r#"{
"projects": {
"/Users/alice/myproject": {
"lastCost": 1.23,
"lastModelUsage": {
"claude-sonnet-4-5": {
"costUSD": 1.0,
"inputTokens": 1000,
"outputTokens": 500,
"cacheCreationInputTokens": 0,
"cacheReadInputTokens": 0,
"webSearchRequests": 0
},
"claude-haiku-4-5-20251001": {
"costUSD": 0.23,
"inputTokens": 2000,
"outputTokens": 100,
"cacheCreationInputTokens": 0,
"cacheReadInputTokens": 0,
"webSearchRequests": 0
}
}
},
"/Users/alice/other": {
"lastCost": 0.5,
"lastModelUsage": {
"claude-opus-4": {
"costUSD": 0.5,
"inputTokens": 500,
"outputTokens": 200,
"cacheCreationInputTokens": 0,
"cacheReadInputTokens": 0,
"webSearchRequests": 0
}
}
}
}
}"#,
);
let stats = parse_claude_global(dir.path()).expect("should parse");
assert_eq!(stats.projects.len(), 2);
assert_eq!(stats.projects[0].name, "myproject");
assert_eq!(stats.projects[0].model_usage.len(), 2);
assert!((stats.total_last_cost - 1.73).abs() < 0.01);
}
#[test]
fn test_skips_empty_projects() {
let dir = TempDir::new().unwrap();
write_claude_json(
&dir,
r#"{
"projects": {
"/tmp/empty": {
"lastCost": 0.0,
"lastModelUsage": {}
},
"/tmp/real": {
"lastCost": 0.42,
"lastModelUsage": {
"claude-sonnet-4-5": {
"costUSD": 0.42,
"inputTokens": 100,
"outputTokens": 50,
"cacheCreationInputTokens": 0,
"cacheReadInputTokens": 0,
"webSearchRequests": 0
}
}
}
}
}"#,
);
let stats = parse_claude_global(dir.path()).expect("should parse");
assert_eq!(stats.projects.len(), 1);
assert_eq!(stats.projects[0].name, "real");
}
#[test]
fn test_total_tokens() {
let usage = ModelLastUsage {
cost_usd: 1.0,
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 200,
cache_read_input_tokens: 100,
web_search_requests: 0,
};
assert_eq!(usage.total_tokens(), 1800);
}
}