1use std::collections::{BTreeMap, HashMap};
11use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14
15use chrono::{DateTime, Duration, Utc};
16use serde::Deserialize;
17
18use super::wire::{HostCounts, HostUsageFile};
19
20pub(crate) const WINDOW_DAYS: i64 = 30;
21pub(crate) const MAX_FILES: usize = 2_000;
22pub(crate) const MAX_FILE_BYTES: u64 = 64 << 20;
23const MAX_DEPTH: usize = 6;
24
25#[derive(Deserialize)]
26struct Usage {
27 #[serde(default)]
28 input_tokens: Option<i64>,
29 #[serde(default)]
30 output_tokens: Option<i64>,
31 #[serde(default)]
32 cache_read_input_tokens: Option<i64>,
33 #[serde(default)]
34 cache_creation_input_tokens: Option<i64>,
35 #[serde(default)]
36 cached_input_tokens: Option<i64>,
37 #[serde(default)]
38 cache_write_input_tokens: Option<i64>,
39}
40
41#[derive(Deserialize)]
42struct ClaudeMessage {
43 #[serde(default)]
44 id: Option<String>,
45 #[serde(default)]
46 model: Option<String>,
47 #[serde(default)]
48 usage: Option<Usage>,
49}
50
51#[derive(Deserialize)]
52struct ClaudeLine {
53 #[serde(rename = "type", default)]
54 kind: Option<String>,
55 #[serde(default)]
56 timestamp: Option<String>,
57 #[serde(default)]
58 cwd: Option<String>,
59 #[serde(rename = "requestId", default)]
60 request_id: Option<String>,
61 #[serde(default)]
62 message: Option<ClaudeMessage>,
63}
64
65#[derive(Deserialize)]
66struct CodexInfo {
67 #[serde(default)]
68 last_token_usage: Option<Usage>,
69}
70
71#[derive(Deserialize)]
72struct CodexPayload {
73 #[serde(rename = "type", default)]
74 kind: Option<String>,
75 #[serde(default)]
76 cwd: Option<String>,
77 #[serde(default)]
78 model: Option<String>,
79 #[serde(default)]
80 info: Option<CodexInfo>,
81}
82
83#[derive(Deserialize)]
84struct CodexLine {
85 #[serde(rename = "type", default)]
86 kind: Option<String>,
87 #[serde(default)]
88 timestamp: Option<String>,
89 #[serde(default)]
90 payload: Option<CodexPayload>,
91}
92
93#[derive(Default, Clone)]
95struct FileUsage {
96 host: &'static str,
97 offset: u64,
98 cwd: String,
99 model: String,
100 last_id: String,
101 days: BTreeMap<String, BTreeMap<String, HostCounts>>,
102 minutes: BTreeMap<String, BTreeMap<String, HostCounts>>,
105}
106
107fn cache() -> &'static Mutex<HashMap<PathBuf, FileUsage>> {
108 static C: OnceLock<Mutex<HashMap<PathBuf, FileUsage>>> = OnceLock::new();
109 C.get_or_init(|| Mutex::new(HashMap::new()))
110}
111
112fn modified_after(path: &Path, cutoff: std::time::SystemTime) -> Option<u64> {
113 let meta = std::fs::symlink_metadata(path).ok()?;
114 (meta.file_type().is_file() && meta.modified().ok()? >= cutoff).then_some(meta.len())
115}
116
117fn sources(home: &Path, cutoff: std::time::SystemTime) -> Vec<(&'static str, PathBuf, u64)> {
118 let mut found = Vec::new();
119 if let Ok(projects) = std::fs::read_dir(home.join(".claude").join("projects")) {
120 for dir in projects.filter_map(Result::ok) {
121 if !dir.file_type().is_ok_and(|t| t.is_dir()) {
122 continue;
123 }
124 let Ok(files) = std::fs::read_dir(dir.path()) else {
125 continue;
126 };
127 for file in files.filter_map(Result::ok) {
128 let path = file.path();
129 if path.extension().is_some_and(|e| e == "jsonl")
130 && let Some(len) = modified_after(&path, cutoff)
131 {
132 found.push(("claude-code", path, len));
133 }
134 }
135 }
136 }
137 let mut stack = vec![(home.join(".codex").join("sessions"), 0usize)];
138 while let Some((dir, depth)) = stack.pop() {
139 let Ok(entries) = std::fs::read_dir(&dir) else {
140 continue;
141 };
142 for entry in entries.filter_map(Result::ok) {
143 let Ok(kind) = entry.file_type() else {
144 continue;
145 };
146 let path = entry.path();
147 if kind.is_dir() && depth < MAX_DEPTH {
148 stack.push((path, depth + 1));
149 } else if kind.is_file()
150 && entry.file_name().to_string_lossy().starts_with("rollout-")
151 && path.extension().is_some_and(|e| e == "jsonl")
152 && let Some(len) = modified_after(&path, cutoff)
153 {
154 found.push(("codex", path, len));
155 }
156 }
157 }
158 found.sort_by_key(|a| std::cmp::Reverse(a.2));
159 found.truncate(MAX_FILES);
160 found
161}
162
163fn add(usage: &mut FileUsage, stamp: &str, model: &str, now: DateTime<Utc>, counts: &HostCounts) {
165 let add_to = |row: &mut HostCounts| {
166 row.requests += counts.requests;
167 row.input += counts.input;
168 row.cache_read += counts.cache_read;
169 row.cache_write += counts.cache_write;
170 row.output += counts.output;
171 };
172 let valid_day = stamp.get(..10).filter(|d| super::request::is_day(d));
173 let day = valid_day.map_or_else(|| now.date_naive().to_string(), str::to_string);
174 let model: String = if model.is_empty() { "unknown" } else { model }
175 .chars()
176 .take(80)
177 .collect();
178 add_to(
179 usage
180 .days
181 .entry(day)
182 .or_default()
183 .entry(model.clone())
184 .or_default(),
185 );
186 if let Some(minute) = valid_day
187 .and(stamp.get(..16))
188 .filter(|m| m.as_bytes().get(10) == Some(&b'T'))
189 {
190 add_to(
191 usage
192 .minutes
193 .entry(minute.to_owned())
194 .or_default()
195 .entry(model)
196 .or_default(),
197 );
198 }
199}
200
201fn claude_line(usage: &mut FileUsage, line: &[u8], now: DateTime<Utc>) {
202 let marker = |m: &[u8]| line.windows(m.len()).any(|w| w == m);
203 if !marker(b"\"usage\"") || !marker(b"\"assistant\"") {
204 return;
205 }
206 let Ok(record) = serde_json::from_slice::<ClaudeLine>(line) else {
207 return;
208 };
209 if record.kind.as_deref() != Some("assistant") {
210 return;
211 }
212 let Some(message) = record.message else {
213 return;
214 };
215 let Some(counts) = message.usage else { return };
216 let model = message.model.unwrap_or_default();
217 let id = message.id.or(record.request_id).unwrap_or_default();
218 if model == "<synthetic>" || (!id.is_empty() && id == usage.last_id) {
219 return;
220 }
221 usage.last_id = id;
222 if let Some(cwd) = record.cwd {
223 usage.cwd = cwd;
224 }
225 let counts = HostCounts {
226 requests: 1,
227 input: counts.input_tokens.unwrap_or(0),
228 cache_read: counts.cache_read_input_tokens.unwrap_or(0),
229 cache_write: counts.cache_creation_input_tokens.unwrap_or(0),
230 output: counts.output_tokens.unwrap_or(0),
231 };
232 add(
233 usage,
234 record.timestamp.as_deref().unwrap_or(""),
235 &model,
236 now,
237 &counts,
238 );
239}
240
241fn codex_line(usage: &mut FileUsage, line: &[u8], now: DateTime<Utc>) {
242 let marker = |m: &[u8]| line.windows(m.len()).any(|w| w == m);
243 if !marker(b"\"token_count\"") && !marker(b"\"turn_context\"") && !marker(b"\"session_meta\"") {
244 return;
245 }
246 let Ok(record) = serde_json::from_slice::<CodexLine>(line) else {
247 return;
248 };
249 let Some(payload) = record.payload else {
250 return;
251 };
252 match record.kind.as_deref() {
253 Some("session_meta") => {
254 if let Some(cwd) = payload.cwd {
255 usage.cwd = cwd;
256 }
257 }
258 Some("turn_context") => {
259 if let Some(model) = payload.model {
260 usage.model = model.chars().take(80).collect();
261 }
262 if let Some(cwd) = payload.cwd {
263 usage.cwd = cwd;
264 }
265 }
266 Some("event_msg") if payload.kind.as_deref() == Some("token_count") => {
267 let Some(counts) = payload.info.and_then(|i| i.last_token_usage) else {
268 return;
269 };
270 let model = usage.model.clone();
271 let cached = counts.cached_input_tokens.unwrap_or(0);
272 let counts = HostCounts {
273 requests: 1,
274 input: (counts.input_tokens.unwrap_or(0) - cached).max(0),
275 cache_read: cached,
276 cache_write: counts.cache_write_input_tokens.unwrap_or(0),
277 output: counts.output_tokens.unwrap_or(0),
278 };
279 add(
280 usage,
281 record.timestamp.as_deref().unwrap_or(""),
282 &model,
283 now,
284 &counts,
285 );
286 }
287 _ => {}
288 }
289}
290
291fn read_new(path: &Path, len: u64, usage: &mut FileUsage, now: DateTime<Utc>) {
292 let Ok(mut file) = std::fs::File::open(path) else {
293 return;
294 };
295 let mut start = usage.offset.max(len.saturating_sub(MAX_FILE_BYTES));
297 if file.seek(SeekFrom::Start(start)).is_err() {
298 return;
299 }
300 let mut reader = BufReader::new(file.take(len - start));
301 let mut line = Vec::new();
302 if start > usage.offset {
303 let Ok(read) = reader.read_until(b'\n', &mut line) else {
305 return;
306 };
307 start += read as u64;
308 }
309 usage.offset = start;
310 loop {
311 line.clear();
312 let Ok(read) = reader.read_until(b'\n', &mut line) else {
313 break;
314 };
315 if read == 0 || line.last() != Some(&b'\n') {
316 break;
317 }
318 usage.offset += read as u64;
319 if usage.host == "codex" {
320 codex_line(usage, &line, now);
321 } else {
322 claude_line(usage, &line, now);
323 }
324 }
325}
326
327fn under(folder: &str, root: &str) -> bool {
328 let folder = folder.trim_end_matches('/');
329 let root = root.trim_end_matches('/');
330 !root.is_empty() && (folder == root || folder.starts_with(&format!("{root}/")))
331}
332
333pub(crate) fn collect(home: &Path, roots: &[String], now: DateTime<Utc>) -> Vec<HostUsageFile> {
337 scan(home, now, WINDOW_DAYS, |cwd| {
338 roots.iter().any(|r| under(cwd, r)).then_some(0)
339 })
340 .into_iter()
341 .map(|(_, mut file)| {
342 file.project.clone_from(&roots[0]);
344 file
345 })
346 .collect()
347}
348
349pub(crate) fn collect_assigned(
354 home: &Path,
355 roots: &[String],
356 now: DateTime<Utc>,
357 window_days: i64,
358) -> Vec<(usize, HostUsageFile)> {
359 scan(home, now, window_days.max(1), |cwd| {
360 roots
361 .iter()
362 .enumerate()
363 .filter(|(_, root)| under(cwd, root))
364 .max_by_key(|(_, root)| root.trim_end_matches('/').len())
365 .map(|(index, _)| index)
366 })
367}
368
369pub fn usage_between(
374 home: &Path,
375 roots: &[String],
376 others: &[String],
377 now: DateTime<Utc>,
378 from: DateTime<Utc>,
379 to: DateTime<Utc>,
380) -> BTreeMap<String, HostCounts> {
381 let minute = |t: DateTime<Utc>| t.format("%Y-%m-%dT%H:%M").to_string();
382 let (first, last) = (minute(from), minute(to));
383 let mut out: BTreeMap<String, HostCounts> = BTreeMap::new();
384 if first > last {
385 return out;
386 }
387 visit(home, now, WINDOW_DAYS, |usage| {
388 let depth = |list: &[String]| {
389 list.iter()
390 .filter(|root| under(&usage.cwd, root))
391 .map(|root| root.trim_end_matches('/').len())
392 .max()
393 };
394 let Some(own) = depth(roots) else {
395 return;
396 };
397 if depth(others).is_some_and(|other| other > own) {
398 return;
399 }
400 for models in usage
401 .minutes
402 .range(first.clone()..=last.clone())
403 .map(|(_, models)| models)
404 {
405 for (model, counts) in models {
406 let row = out.entry(model.clone()).or_default();
407 row.requests += counts.requests;
408 row.input += counts.input;
409 row.cache_read += counts.cache_read;
410 row.cache_write += counts.cache_write;
411 row.output += counts.output;
412 }
413 }
414 });
415 out
416}
417
418fn visit(home: &Path, now: DateTime<Utc>, window_days: i64, mut each: impl FnMut(&FileUsage)) {
421 let cutoff_time = std::time::SystemTime::now()
422 - std::time::Duration::from_secs(u64::try_from(window_days).unwrap_or(30) * 86_400);
423 let found = sources(home, cutoff_time);
424 let mut map = cache()
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 let live: std::collections::HashSet<&PathBuf> = found.iter().map(|(_, p, _)| p).collect();
428 map.retain(|path, _| live.contains(path));
429 for (host, path, len) in &found {
430 let usage = map.entry(path.clone()).or_insert_with(|| FileUsage {
431 host,
432 ..FileUsage::default()
433 });
434 if *len < usage.offset {
435 *usage = FileUsage {
436 host,
437 ..FileUsage::default()
438 };
439 }
440 if *len > usage.offset {
441 read_new(path, *len, usage, now);
442 }
443 each(usage);
444 }
445}
446
447fn scan(
448 home: &Path,
449 now: DateTime<Utc>,
450 window_days: i64,
451 assign: impl Fn(&str) -> Option<usize>,
452) -> Vec<(usize, HostUsageFile)> {
453 let cutoff_day = (now - Duration::days(window_days - 1))
454 .date_naive()
455 .to_string();
456 let mut out = Vec::new();
457 visit(home, now, window_days, |usage| {
458 let Some(assigned) = assign(&usage.cwd) else {
459 return;
460 };
461 let days: BTreeMap<String, BTreeMap<String, HostCounts>> = usage
462 .days
463 .iter()
464 .filter(|(day, _)| **day >= cutoff_day)
465 .map(|(d, m)| (d.clone(), m.clone()))
466 .collect();
467 if days.is_empty() {
468 return;
469 }
470 out.push((
471 assigned,
472 HostUsageFile {
473 project: String::new(),
474 host: usage.host.to_string(),
475 days,
476 },
477 ));
478 });
479 out
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 fn line(stamp: &str, id: &str, input: i64, output: i64, cwd: &str) -> String {
487 serde_json::json!({
488 "type": "assistant", "timestamp": stamp, "cwd": cwd, "requestId": id,
489 "message": {"id": id, "model": "claude-opus-5-5",
490 "usage": {"input_tokens": input, "output_tokens": output}}
491 })
492 .to_string()
493 + "\n"
494 }
495
496 #[test]
499 fn usage_between_counts_the_requests_inside_the_window() {
500 let home = tempfile::tempdir().unwrap();
501 let dir = home.path().join(".claude/projects/p");
502 std::fs::create_dir_all(&dir).unwrap();
503 let now = Utc::now();
504 let minute = |m: i64| (now - Duration::minutes(m)).to_rfc3339();
505 let log = [
506 line(&minute(90), "r1", 100, 10, "/work/payments"),
507 line(&minute(30), "r2", 200, 20, "/work/payments"),
508 line(&minute(10), "r3", 400, 40, "/work/payments/src"),
509 ]
510 .concat();
511 std::fs::write(dir.join("s.jsonl"), log).unwrap();
512 std::fs::write(
513 dir.join("other.jsonl"),
514 line(&minute(30), "o1", 9_000, 900, "/work/other"),
515 )
516 .unwrap();
517 let roots = vec!["/work/payments".to_owned()];
518 let counts = usage_between(
519 home.path(),
520 &roots,
521 &[],
522 now,
523 now - Duration::minutes(45),
524 now,
525 );
526 let row = &counts["claude-opus-5-5"];
527 assert_eq!((row.requests, row.input, row.output), (2, 600, 60));
528 let all = collect_assigned(home.path(), &roots, now, 2);
529 let total: i64 = all
530 .iter()
531 .flat_map(|(_, file)| file.days.values())
532 .flat_map(BTreeMap::values)
533 .map(|c| c.input)
534 .sum();
535 assert_eq!(total, 700, "the day buckets keep every request");
536 assert!(
537 usage_between(
538 home.path(),
539 &roots,
540 &[],
541 now,
542 now,
543 now - Duration::minutes(1)
544 )
545 .is_empty()
546 );
547
548 std::fs::write(
550 dir.join("nested.jsonl"),
551 line(&minute(20), "n1", 50, 5, "/work/payments/billing"),
552 )
553 .unwrap();
554 let nested = vec!["/work/payments/billing".to_owned()];
555 let window = (now - Duration::minutes(45), now);
556 let outer = usage_between(home.path(), &roots, &nested, now, window.0, window.1);
557 assert_eq!(outer["claude-opus-5-5"].input, 600);
558 let inner = usage_between(home.path(), &nested, &roots, now, window.0, window.1);
559 assert_eq!(inner["claude-opus-5-5"].input, 50);
560 }
561}