claude_code_stats/source/
cost_scanner.rs1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use chrono::Utc;
5use serde::Deserialize;
6use walkdir::WalkDir;
7
8use crate::types::CostData;
9
10#[derive(Debug, Deserialize)]
11struct JournalEntry {
12 #[serde(rename = "type")]
13 entry_type: Option<String>,
14 message: Option<MessageData>,
15}
16
17#[derive(Debug, Deserialize)]
18struct MessageData {
19 id: Option<String>,
20 model: Option<String>,
21 usage: Option<UsageData>,
22}
23
24#[derive(Debug, Deserialize)]
25struct UsageData {
26 input_tokens: Option<u64>,
27 output_tokens: Option<u64>,
28 cache_creation_input_tokens: Option<u64>,
29 cache_read_input_tokens: Option<u64>,
30}
31
32struct TokenCosts {
33 input_per_mtok: f64,
34 output_per_mtok: f64,
35 cache_write_per_mtok: f64,
36 cache_read_per_mtok: f64,
37}
38
39fn model_costs(model: &str) -> TokenCosts {
40 let lower = model.to_lowercase();
41 if lower.contains("opus") {
42 TokenCosts {
43 input_per_mtok: 15.0,
44 output_per_mtok: 75.0,
45 cache_write_per_mtok: 18.75,
46 cache_read_per_mtok: 1.50,
47 }
48 } else if lower.contains("haiku") {
49 TokenCosts {
50 input_per_mtok: 0.80,
51 output_per_mtok: 4.0,
52 cache_write_per_mtok: 1.0,
53 cache_read_per_mtok: 0.08,
54 }
55 } else {
56 TokenCosts {
58 input_per_mtok: 3.0,
59 output_per_mtok: 15.0,
60 cache_write_per_mtok: 3.75,
61 cache_read_per_mtok: 0.30,
62 }
63 }
64}
65
66fn scan_dirs() -> Vec<PathBuf> {
67 let mut dirs = Vec::new();
68 if let Some(home) = dirs::home_dir() {
69 dirs.push(home.join(".claude").join("projects"));
70 }
71 if let Some(config) = dirs::config_dir() {
72 dirs.push(config.join("claude").join("projects"));
73 }
74 dirs
75}
76
77pub fn scan_cost_data() -> Option<CostData> {
78 let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
79 let cutoff_ts = thirty_days_ago.timestamp();
80
81 let mut total_cost = 0.0;
82 let mut models_seen = HashSet::new();
83 let mut seen_ids = HashSet::new();
84
85 for dir in scan_dirs() {
86 if !dir.exists() {
87 continue;
88 }
89
90 for entry in WalkDir::new(&dir)
91 .follow_links(false)
92 .into_iter()
93 .filter_map(|e| e.ok())
94 {
95 let path = entry.path();
96 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
97 continue;
98 }
99
100 if let Ok(meta) = path.metadata() {
102 if let Ok(modified) = meta.modified() {
103 let mod_secs = modified
104 .duration_since(std::time::UNIX_EPOCH)
105 .unwrap_or_default()
106 .as_secs() as i64;
107 if mod_secs < cutoff_ts {
108 continue;
109 }
110 }
111 }
112
113 if let Ok(contents) = std::fs::read_to_string(path) {
114 for line in contents.lines() {
115 let entry: JournalEntry = match serde_json::from_str(line) {
116 Ok(e) => e,
117 Err(_) => continue,
118 };
119
120 if entry.entry_type.as_deref() != Some("assistant") {
121 continue;
122 }
123
124 let message = match entry.message {
125 Some(m) => m,
126 None => continue,
127 };
128
129 let usage = match message.usage {
130 Some(u) => u,
131 None => continue,
132 };
133
134 if let Some(id) = &message.id {
136 if !seen_ids.insert(id.clone()) {
137 continue;
138 }
139 }
140
141 let model_name = message.model.as_deref().unwrap_or("unknown");
142 models_seen.insert(model_name.to_string());
143
144 let costs = model_costs(model_name);
145 let input = usage.input_tokens.unwrap_or(0) as f64;
146 let output = usage.output_tokens.unwrap_or(0) as f64;
147 let cache_write = usage.cache_creation_input_tokens.unwrap_or(0) as f64;
148 let cache_read = usage.cache_read_input_tokens.unwrap_or(0) as f64;
149
150 total_cost += input * costs.input_per_mtok / 1_000_000.0;
151 total_cost += output * costs.output_per_mtok / 1_000_000.0;
152 total_cost += cache_write * costs.cache_write_per_mtok / 1_000_000.0;
153 total_cost += cache_read * costs.cache_read_per_mtok / 1_000_000.0;
154 }
155 }
156 }
157 }
158
159 if total_cost == 0.0 && models_seen.is_empty() {
160 return None;
161 }
162
163 let mut models: Vec<String> = models_seen.into_iter().collect();
164 models.sort();
165
166 Some(CostData {
167 last_30d_cost_usd: Some((total_cost * 100.0).round() / 100.0),
168 models,
169 })
170}