Skip to main content

claude_code_stats/source/
cost_scanner.rs

1use 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        // Default to Sonnet pricing
57        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            // Check file modification time as a quick filter
101            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                    // Deduplicate by message ID
135                    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}