1use std::collections::{BTreeMap, HashSet};
9use std::io::BufRead;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use forge_primitives::token_usage::{UsageReport, UsageRow, WindowUsage};
14use serde::{Deserialize, Serialize};
15use time::format_description::well_known::Rfc3339;
16use time::{Date, Duration, Month, OffsetDateTime};
17use time_tz::{OffsetDateTimeExt, Tz};
18
19use self::pricing::PricingTable;
20
21pub mod pricing;
22
23const WORKTREE_MARKER: &str = "--claude-worktrees-";
27
28fn fold_project(slug: &str) -> (String, bool) {
38 let projects_root = home_dir().map(|home| home.join("Projects"));
39 let prefix = projects_root.as_deref().map(encoded_projects_prefix).unwrap_or_default();
40 let root = projects_root.as_deref().unwrap_or_else(|| Path::new(""));
41 fold_project_in(slug, &prefix, root)
42}
43
44fn fold_project_in(slug: &str, projects_prefix: &str, projects_root: &Path) -> (String, bool) {
49 if let Some(idx) = slug.find(WORKTREE_MARKER) {
52 return fold_project_in(&slug[..idx], projects_prefix, projects_root);
53 }
54 if !projects_prefix.is_empty()
57 && let Some(remainder) = slug.strip_prefix(projects_prefix)
58 && !remainder.is_empty()
59 {
60 return resolve_project_name(remainder, projects_root);
61 }
62 if slug.starts_with("-private-tmp") || slug.starts_with("-tmp") {
64 return ("scratch".to_owned(), true);
65 }
66 (basename_fallback(slug), true)
69}
70
71fn resolve_project_name(remainder: &str, projects_root: &Path) -> (String, bool) {
77 let tokens: Vec<&str> = remainder.split('-').filter(|token| !token.is_empty()).collect();
82 for run_len in (1..=tokens.len()).rev() {
83 let candidate = tokens[..run_len].join("-");
84 if projects_root.join(&candidate).is_dir() {
85 return (candidate, true);
86 }
87 }
88 let whole = tokens.join("-");
92 (if whole.is_empty() { remainder.to_owned() } else { whole }, false)
93}
94
95fn basename_fallback(slug: &str) -> String {
98 slug.rsplit('-').find(|token| !token.is_empty()).unwrap_or(slug).to_owned()
99}
100
101fn encoded_projects_prefix(projects_root: &Path) -> String {
104 format!("{}-", projects_root.to_string_lossy().replace(['/', '.'], "-"))
105}
106
107fn home_dir() -> Option<PathBuf> {
108 std::env::var_os("HOME").filter(|s| !s.is_empty()).map(PathBuf::from)
109}
110
111#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct TokenCounts {
116 pub input: u64,
117 pub cache_write_1h: u64,
118 pub cache_write_5m: u64,
119 pub cache_read: u64,
120 pub output: u64,
121}
122
123impl TokenCounts {
124 fn add(&mut self, other: &TokenCounts) {
125 self.input = self.input.saturating_add(other.input);
126 self.cache_write_1h = self.cache_write_1h.saturating_add(other.cache_write_1h);
127 self.cache_write_5m = self.cache_write_5m.saturating_add(other.cache_write_5m);
128 self.cache_read = self.cache_read.saturating_add(other.cache_read);
129 self.output = self.output.saturating_add(other.output);
130 }
131}
132
133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub struct FileUsageSummary {
139 pub mtime: SystemTime,
140 pub size: u64,
141 pub folded_project: String,
142 #[serde(default)]
148 pub project_resolved: bool,
149 pub by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>>,
150}
151
152impl FileUsageSummary {
153 pub fn refresh_unresolved_project(&mut self, path: &Path) {
158 if self.project_resolved {
159 return;
160 }
161 let Some(slug) = path.parent().and_then(Path::file_name) else {
162 return;
163 };
164 let (name, resolved) = fold_project(&slug.to_string_lossy());
165 self.folded_project = name;
166 self.project_resolved = resolved;
167 }
168}
169
170pub fn usage_files(projects_dir: &Path) -> Vec<PathBuf> {
175 let mut files = Vec::new();
176 let project_dirs = match std::fs::read_dir(projects_dir) {
177 Ok(dirs) => dirs,
178 Err(error) => {
179 tracing::warn!(
180 target: "forge_agent::env::token_usage",
181 %error,
182 path = %projects_dir.display(),
183 "reading the projects dir failed; the usage scan answers empty",
184 );
185 return files;
186 }
187 };
188 for project in project_dirs.flatten() {
189 if !project.file_type().is_ok_and(|t| t.is_dir()) {
190 continue;
191 }
192 let Ok(session_files) = std::fs::read_dir(project.path()) else {
193 continue;
194 };
195 for session in session_files.flatten() {
196 let path = session.path();
197 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
198 continue;
199 }
200 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
201 if name.contains(".sync-conflict") {
202 continue;
203 }
204 files.push(path);
205 }
206 }
207 files
208}
209
210pub fn parse_file(path: &Path, tz: &Tz) -> Option<FileUsageSummary> {
214 let metadata = std::fs::metadata(path).ok()?;
215 let mtime = metadata.modified().ok()?;
216 let size = metadata.len();
217 let slug = path.parent()?.file_name()?.to_string_lossy().into_owned();
218 let (folded_project, project_resolved) = fold_project(&slug);
219
220 let file = std::fs::File::open(path).ok()?;
221 let reader = std::io::BufReader::new(file);
222 let mut by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>> = BTreeMap::new();
223 let mut seen: HashSet<String> = HashSet::new();
224 let mut read_error_logged = false;
225 let mut timestamp_error_logged = false;
226 for line in reader.lines() {
227 let line = match line {
228 Ok(line) => line,
229 Err(error) => {
233 if !read_error_logged {
234 tracing::warn!(
235 target: "forge_agent::env::token_usage",
236 %error,
237 path = %path.display(),
238 "skipping an unreadable line; this file's usage may undercount",
239 );
240 read_error_logged = true;
241 }
242 continue;
243 }
244 };
245 let Ok(record) = serde_json::from_str::<Record>(&line) else {
246 continue;
247 };
248 if record.kind.as_deref() != Some("assistant") {
249 continue;
250 }
251 let (Some(message), Some(timestamp)) = (record.message, record.timestamp) else {
252 continue;
253 };
254 let (Some(id), Some(model), Some(usage)) = (message.id, message.model, message.usage)
255 else {
256 continue;
257 };
258 if !seen.insert(id) {
261 continue;
262 }
263 let Some(day) = calendar_day(×tamp, tz) else {
264 if !timestamp_error_logged {
268 tracing::warn!(
269 target: "forge_agent::env::token_usage",
270 path = %path.display(),
271 "skipping a record with an unparseable timestamp; this file's usage may undercount",
272 );
273 timestamp_error_logged = true;
274 }
275 continue;
276 };
277 by_model_day.entry(model).or_default().entry(day).or_default().add(&usage.into_counts());
278 }
279 Some(FileUsageSummary { mtime, size, folded_project, project_resolved, by_model_day })
280}
281
282fn calendar_day(timestamp: &str, tz: &Tz) -> Option<String> {
286 let date = OffsetDateTime::parse(timestamp, &Rfc3339).ok()?.to_timezone(tz).date();
287 Some(format!("{:04}-{:02}-{:02}", date.year(), u8::from(date.month()), date.day()))
288}
289
290#[derive(Deserialize)]
294struct Record {
295 #[serde(rename = "type")]
296 kind: Option<String>,
297 timestamp: Option<String>,
298 message: Option<RecordMessage>,
299}
300
301#[derive(Deserialize)]
302struct RecordMessage {
303 id: Option<String>,
304 model: Option<String>,
305 usage: Option<RecordUsage>,
306}
307
308#[derive(Deserialize)]
309struct RecordUsage {
310 #[serde(default)]
311 input_tokens: u64,
312 #[serde(default)]
313 output_tokens: u64,
314 #[serde(default)]
315 cache_read_input_tokens: u64,
316 #[serde(default)]
317 cache_creation_input_tokens: u64,
318 #[serde(default)]
319 cache_creation: Option<CacheCreation>,
320}
321
322impl RecordUsage {
323 fn into_counts(self) -> TokenCounts {
324 let mut counts = TokenCounts {
325 input: self.input_tokens,
326 cache_read: self.cache_read_input_tokens,
327 output: self.output_tokens,
328 ..TokenCounts::default()
329 };
330 match self.cache_creation {
333 Some(split) => {
334 counts.cache_write_1h = split.ephemeral_1h_input_tokens;
335 counts.cache_write_5m = split.ephemeral_5m_input_tokens;
336 }
337 None => counts.cache_write_5m = self.cache_creation_input_tokens,
338 }
339 counts
340 }
341}
342
343#[derive(Deserialize)]
344struct CacheCreation {
345 #[serde(default)]
346 ephemeral_1h_input_tokens: u64,
347 #[serde(default)]
348 ephemeral_5m_input_tokens: u64,
349}
350
351pub fn roll_up(
358 summaries: &[FileUsageSummary],
359 pricing: &PricingTable,
360 now: OffsetDateTime,
361) -> UsageReport {
362 let today = now.date();
363 let week_start = today - Duration::days(i64::from(today.weekday().number_days_from_monday()));
364 let month_start = Date::from_calendar_date(today.year(), today.month(), 1).unwrap_or(today);
365
366 let mut day = WindowAcc::default();
367 let mut week = WindowAcc::default();
368 let mut month = WindowAcc::default();
369 let mut lifetime = WindowAcc::default();
370
371 for summary in summaries {
372 for (model, days) in &summary.by_model_day {
373 for (day_str, counts) in days {
374 let Some(date) = parse_date(day_str) else {
375 continue;
376 };
377 lifetime.add(&summary.folded_project, model, counts);
378 if date >= month_start {
379 month.add(&summary.folded_project, model, counts);
380 }
381 if date >= week_start {
382 week.add(&summary.folded_project, model, counts);
383 }
384 if date == today {
385 day.add(&summary.folded_project, model, counts);
386 }
387 }
388 }
389 }
390
391 UsageReport {
392 today: day.finish(pricing),
393 week: week.finish(pricing),
394 month: month.finish(pricing),
395 lifetime: lifetime.finish(pricing),
396 pricing_available: !pricing.is_empty(),
397 }
398}
399
400#[derive(Default)]
404struct WindowAcc {
405 by_project_model: BTreeMap<String, BTreeMap<String, TokenCounts>>,
406}
407
408impl WindowAcc {
409 fn add(&mut self, project: &str, model: &str, counts: &TokenCounts) {
410 self.by_project_model
411 .entry(project.to_owned())
412 .or_default()
413 .entry(model.to_owned())
414 .or_default()
415 .add(counts);
416 }
417
418 fn finish(&self, pricing: &PricingTable) -> WindowUsage {
419 let mut model_totals: BTreeMap<String, TokenCounts> = BTreeMap::new();
420 for models in self.by_project_model.values() {
421 for (model, counts) in models {
422 model_totals.entry(model.clone()).or_default().add(counts);
423 }
424 }
425
426 let mut by_model: Vec<UsageRow> = model_totals
427 .iter()
428 .map(|(model, counts)| make_row(model.clone(), counts, cost_of(model, counts, pricing)))
429 .collect();
430 sort_by_cost_desc(&mut by_model);
431
432 let mut by_project: Vec<UsageRow> = self
433 .by_project_model
434 .iter()
435 .map(|(project, models)| {
436 let mut counts = TokenCounts::default();
437 let mut cost = 0.0;
438 for (model, model_counts) in models {
439 counts.add(model_counts);
440 cost += cost_of(model, model_counts, pricing);
441 }
442 make_row(project.clone(), &counts, cost)
443 })
444 .collect();
445 sort_by_cost_desc(&mut by_project);
446
447 let mut total_counts = TokenCounts::default();
448 let mut total_cost = 0.0;
449 for (model, counts) in &model_totals {
450 total_counts.add(counts);
451 total_cost += cost_of(model, counts, pricing);
452 }
453
454 WindowUsage {
455 by_model,
456 by_project,
457 total: make_row("TOTAL".to_owned(), &total_counts, total_cost),
458 }
459 }
460}
461
462fn make_row(label: String, counts: &TokenCounts, cost_usd: f64) -> UsageRow {
463 UsageRow {
464 label,
465 input: counts.input,
466 cache_write_1h: counts.cache_write_1h,
467 cache_write_5m: counts.cache_write_5m,
468 cache_read: counts.cache_read,
469 output: counts.output,
470 cost_usd,
471 }
472}
473
474#[expect(
477 clippy::cast_precision_loss,
478 reason = "token counts stay well under 2^53, so the f64 conversion is exact"
479)]
480fn cost_of(model: &str, counts: &TokenCounts, pricing: &PricingTable) -> f64 {
481 let Some(price) = pricing.price(model) else {
482 return 0.0;
483 };
484 counts.input as f64 * price.input
485 + counts.cache_write_1h as f64 * price.cache_write_1h
486 + counts.cache_write_5m as f64 * price.cache_write_5m
487 + counts.cache_read as f64 * price.cache_read
488 + counts.output as f64 * price.output
489}
490
491fn sort_by_cost_desc(rows: &mut [UsageRow]) {
492 rows.sort_by(|a, b| b.cost_usd.total_cmp(&a.cost_usd).then_with(|| a.label.cmp(&b.label)));
493}
494
495fn parse_date(day: &str) -> Option<Date> {
497 let mut parts = day.split('-');
498 let year: i32 = parts.next()?.parse().ok()?;
499 let month: u8 = parts.next()?.parse().ok()?;
500 let day_of_month: u8 = parts.next()?.parse().ok()?;
501 Date::from_calendar_date(year, Month::try_from(month).ok()?, day_of_month).ok()
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use tempfile::TempDir;
508 use time_tz::timezones;
509
510 const PREFIX: &str = "-Users-developer-Projects-";
511
512 fn utc() -> &'static Tz {
513 timezones::db::UTC
514 }
515
516 #[test]
517 fn calendar_day_uses_the_injected_timezone() {
518 let plus_530 = timezones::get_by_name("Asia/Colombo").expect("+5:30 zone");
519 assert_eq!(calendar_day("2026-07-19T20:00:00Z", utc()).as_deref(), Some("2026-07-19"));
521 assert_eq!(calendar_day("2026-07-19T20:00:00Z", plus_530).as_deref(), Some("2026-07-20"));
522 assert_eq!(calendar_day("not-a-timestamp", utc()), None);
523 }
524
525 fn projects_root_with(dirs: &[&str]) -> TempDir {
526 let td = tempfile::tempdir().expect("tempdir");
527 for dir in dirs {
528 std::fs::create_dir_all(td.path().join(dir)).expect("mkdir");
529 }
530 td
531 }
532
533 fn label(slug: &str, projects_root: &Path) -> String {
536 fold_project_in(slug, PREFIX, projects_root).0
537 }
538
539 #[test]
540 fn worktree_slug_folds_to_parent_repo() {
541 let root = projects_root_with(&["airmail"]);
542 let slug = "-Users-developer-Projects-airmail--claude-worktrees-abuse-hardening";
543 assert_eq!(label(slug, root.path()), "airmail");
544 }
545
546 #[test]
547 fn sub_crate_slug_folds_to_repo_not_dash_split() {
548 let root = projects_root_with(&["forge"]);
549 let slug = "-Users-developer-Projects-forge-crates-forge-test-harness";
550 assert_eq!(label(slug, root.path()), "forge");
551 }
552
553 #[test]
554 fn dashed_project_name_is_not_split_on_internal_dash() {
555 let root = projects_root_with(&["web-api"]);
556 assert_eq!(label("-Users-developer-Projects-web-api", root.path()), "web-api");
557 }
558
559 #[test]
560 fn tmp_slugs_fold_to_scratch() {
561 let root = projects_root_with(&[]);
562 assert_eq!(label("-private-tmp-forge-refresh-0ed1d9d0", root.path()), "scratch");
563 assert_eq!(label("-tmp-scratchpad", root.path()), "scratch");
564 }
565
566 #[test]
567 fn tmp_worktree_slug_folds_to_scratch_via_parent() {
568 let root = projects_root_with(&[]);
569 let slug = "-private-tmp-claude-501--tmpoBGeeH--claude-worktrees-harness-spawn";
570 assert_eq!(label(slug, root.path()), "scratch");
571 }
572
573 #[test]
574 fn absent_repo_keeps_its_whole_dashed_name_and_stays_unresolved() {
575 let root = projects_root_with(&["forge"]);
577 assert_eq!(
578 fold_project_in("-Users-developer-Projects-auto-portal", PREFIX, root.path()),
579 ("auto-portal".to_owned(), false),
580 "an absent repo keeps its whole dashed name and is not marked resolved",
581 );
582 assert_eq!(
583 fold_project_in("-Users-developer-Projects-forge", PREFIX, root.path()),
584 ("forge".to_owned(), true),
585 "a present repo still resolves against the filesystem",
586 );
587 }
588
589 #[test]
590 fn dotted_path_does_not_yield_an_empty_label() {
591 let root = projects_root_with(&["forge"]);
592 assert_eq!(label("-Users-developer-Projects--hidden", root.path()), "hidden");
595 assert_eq!(label("-Users-developer-Projects-forge--config", root.path()), "forge");
597 }
598
599 fn write_session(td: &TempDir, slug: &str, file: &str, lines: &[&str]) -> PathBuf {
600 let dir = td.path().join(slug);
601 std::fs::create_dir_all(&dir).expect("mkdir slug");
602 let path = dir.join(file);
603 std::fs::write(&path, lines.join("\n")).expect("write jsonl");
604 path
605 }
606
607 fn day(summary: &FileUsageSummary, model: &str, day: &str) -> TokenCounts {
608 summary.by_model_day.get(model).and_then(|days| days.get(day)).cloned().unwrap_or_default()
609 }
610
611 #[test]
612 fn duplicate_message_id_counted_once() {
613 let td = tempfile::tempdir().expect("tempdir");
614 let rec = |ts: &str| {
615 format!(
616 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"msg_A","model":"claude-opus-4-8","usage":{{"input_tokens":10,"output_tokens":5}}}}}}"#
617 )
618 };
619 let path = write_session(
620 &td,
621 "-slug",
622 "s.jsonl",
623 &[&rec("2026-07-08T09:30:34.184Z"), &rec("2026-07-08T10:00:00.000Z")],
624 );
625 let summary = parse_file(&path, utc()).expect("parse");
626 let counts = day(&summary, "claude-opus-4-8", "2026-07-08");
627 assert_eq!(counts.input, 10, "the re-logged duplicate id is not added twice");
628 assert_eq!(counts.output, 5);
629 }
630
631 #[test]
632 fn sidechain_record_is_included() {
633 let td = tempfile::tempdir().expect("tempdir");
634 let line = r#"{"type":"assistant","timestamp":"2026-07-08T09:30:34.184Z","isSidechain":true,"message":{"id":"msg_S","model":"claude-opus-4-8","usage":{"input_tokens":7,"output_tokens":2}}}"#;
635 let path = write_session(&td, "-slug", "s.jsonl", &[line]);
636 let summary = parse_file(&path, utc()).expect("parse");
637 assert_eq!(day(&summary, "claude-opus-4-8", "2026-07-08").input, 7);
638 }
639
640 #[test]
641 fn day_bucket_derives_from_timestamp() {
642 let td = tempfile::tempdir().expect("tempdir");
643 let a = r#"{"type":"assistant","timestamp":"2026-07-08T23:59:00.000Z","message":{"id":"a","model":"m","usage":{"output_tokens":1}}}"#;
644 let b = r#"{"type":"assistant","timestamp":"2026-07-09T00:01:00.000Z","message":{"id":"b","model":"m","usage":{"output_tokens":3}}}"#;
645 let path = write_session(&td, "-slug", "s.jsonl", &[a, b]);
646 let summary = parse_file(&path, utc()).expect("parse");
647 assert_eq!(day(&summary, "m", "2026-07-08").output, 1);
648 assert_eq!(day(&summary, "m", "2026-07-09").output, 3);
649 }
650
651 #[test]
652 fn ephemeral_split_maps_and_flat_falls_back_to_5m() {
653 let td = tempfile::tempdir().expect("tempdir");
654 let split = r#"{"type":"assistant","timestamp":"2026-07-08T00:00:00Z","message":{"id":"a","model":"m","usage":{"cache_read_input_tokens":100,"cache_creation_input_tokens":23,"cache_creation":{"ephemeral_1h_input_tokens":20,"ephemeral_5m_input_tokens":3}}}}"#;
655 let flat = r#"{"type":"assistant","timestamp":"2026-07-09T00:00:00Z","message":{"id":"b","model":"m","usage":{"cache_creation_input_tokens":50}}}"#;
656 let path = write_session(&td, "-slug", "s.jsonl", &[split, flat]);
657 let summary = parse_file(&path, utc()).expect("parse");
658
659 let with_split = day(&summary, "m", "2026-07-08");
660 assert_eq!(with_split.cache_write_1h, 20);
661 assert_eq!(with_split.cache_write_5m, 3, "the split is used verbatim when present");
662 assert_eq!(with_split.cache_read, 100);
663
664 let flat_fallback = day(&summary, "m", "2026-07-09");
665 assert_eq!(flat_fallback.cache_write_1h, 0);
666 assert_eq!(flat_fallback.cache_write_5m, 50, "flat total falls back to the 5m tier");
667 }
668
669 #[test]
670 fn parse_file_skips_an_unreadable_line_without_truncating() {
671 let td = tempfile::tempdir().expect("tempdir");
672 let dir = td.path().join("-slug");
673 std::fs::create_dir_all(&dir).expect("mkdir");
674 let record = |id: &str, out: u8| {
675 format!(
676 r#"{{"type":"assistant","timestamp":"2026-07-08T00:00:00Z","message":{{"id":"{id}","model":"m","usage":{{"output_tokens":{out}}}}}}}"#
677 )
678 .into_bytes()
679 };
680 let mut bytes = record("a", 10);
681 bytes.push(b'\n');
682 bytes.extend_from_slice(&[0xff, 0xfe, 0xff]); bytes.push(b'\n');
684 bytes.extend_from_slice(&record("b", 5));
685 let path = dir.join("s.jsonl");
686 std::fs::write(&path, bytes).expect("write");
687
688 let summary = parse_file(&path, utc()).expect("parse");
689 assert_eq!(day(&summary, "m", "2026-07-08").output, 15);
692 }
693
694 #[test]
695 fn parse_file_skips_a_record_with_an_unparseable_timestamp() {
696 let td = tempfile::tempdir().expect("tempdir");
697 let dir = td.path().join("-slug");
698 std::fs::create_dir_all(&dir).expect("mkdir");
699 let rec = |id: &str, ts: &str, out: u64| {
700 format!(
701 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"m","usage":{{"output_tokens":{out}}}}}}}"#
702 )
703 };
704 let path = dir.join("s.jsonl");
705 std::fs::write(
706 &path,
707 [
708 rec("a", "2026-07-08T00:00:00Z", 10),
709 rec("b", "not-a-timestamp", 99),
710 rec("c", "2026-07-08T01:00:00Z", 5),
711 ]
712 .join("\n"),
713 )
714 .expect("write");
715
716 let summary = parse_file(&path, utc()).expect("parse");
717 assert_eq!(day(&summary, "m", "2026-07-08").output, 15);
719 }
720
721 #[test]
722 fn usage_files_skips_sync_conflict_copies() {
723 let td = tempfile::tempdir().expect("tempdir");
724 write_session(&td, "-slug", "real.jsonl", &["{}"]);
725 write_session(&td, "-slug", "real.sync-conflict-20260710-110136-MOGYFY5.jsonl", &["{}"]);
726 let files = usage_files(td.path());
727 assert_eq!(files.len(), 1, "the sync-conflict copy is excluded");
728 assert!(files[0].file_name().and_then(|n| n.to_str()).is_some_and(|n| n == "real.jsonl"));
729 }
730
731 fn counts_out(output: u64) -> TokenCounts {
732 TokenCounts { output, ..TokenCounts::default() }
733 }
734
735 fn summary_of(project: &str, entries: &[(&str, &str, TokenCounts)]) -> FileUsageSummary {
736 let mut by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>> = BTreeMap::new();
737 for (model, day, counts) in entries {
738 by_model_day
739 .entry((*model).to_owned())
740 .or_default()
741 .insert((*day).to_owned(), counts.clone());
742 }
743 FileUsageSummary {
744 mtime: SystemTime::UNIX_EPOCH,
745 size: 0,
746 folded_project: project.to_owned(),
747 project_resolved: true,
748 by_model_day,
749 }
750 }
751
752 fn wednesday() -> OffsetDateTime {
755 Date::from_calendar_date(2026, Month::July, 15).expect("valid date").midnight().assume_utc()
756 }
757
758 fn approx(actual: f64, expected: f64) {
759 assert!((actual - expected).abs() < 1e-9, "expected {expected}, got {actual}");
760 }
761
762 #[test]
763 fn roll_up_buckets_days_into_windows() {
764 let s = summary_of(
765 "forge",
766 &[
767 ("m", "2026-07-15", counts_out(1)), ("m", "2026-07-14", counts_out(2)), ("m", "2026-07-05", counts_out(4)), ("m", "2026-06-20", counts_out(8)), ],
772 );
773 let report = roll_up(&[s], &PricingTable::from_litellm_json("{}"), wednesday());
774 assert_eq!(report.today.total.output, 1);
775 assert_eq!(report.week.total.output, 3);
776 assert_eq!(report.month.total.output, 7);
777 assert_eq!(report.lifetime.total.output, 15);
778 }
779
780 #[test]
781 fn roll_up_flags_pricing_availability() {
782 let summary = summary_of("forge", &[("m", "2026-07-15", counts_out(1))]);
783 let empty = roll_up(
784 std::slice::from_ref(&summary),
785 &PricingTable::from_litellm_json("{}"),
786 wednesday(),
787 );
788 assert!(!empty.pricing_available, "an empty table means pricing is unavailable");
789 let priced = PricingTable::from_litellm_json(
790 r#"{"m":{"input_cost_per_token":0.001,"output_cost_per_token":0.002}}"#,
791 );
792 assert!(roll_up(&[summary], &priced, wednesday()).pricing_available);
793 }
794
795 #[test]
796 fn roll_up_prices_models_and_zeros_unpriced() {
797 let s = summary_of(
798 "forge",
799 &[
800 ("m", "2026-07-15", counts_out(1000)),
801 ("<synthetic>", "2026-07-15", counts_out(500)),
802 ],
803 );
804 let pricing = PricingTable::from_litellm_json(
805 r#"{"m":{"input_cost_per_token":0.001,"output_cost_per_token":0.002}}"#,
806 );
807 let report = roll_up(&[s], &pricing, wednesday());
808 let m = report.today.by_model.iter().find(|r| r.label == "m").expect("m row");
809 let syn =
810 report.today.by_model.iter().find(|r| r.label == "<synthetic>").expect("synthetic row");
811 approx(m.cost_usd, 1000.0 * 0.002);
812 approx(syn.cost_usd, 0.0);
813 approx(report.today.total.cost_usd, 1000.0 * 0.002);
814 }
815
816 #[test]
817 fn roll_up_project_cost_sums_across_models() {
818 let s = summary_of(
819 "forge",
820 &[("cheap", "2026-07-15", counts_out(100)), ("pricey", "2026-07-15", counts_out(100))],
821 );
822 let pricing = PricingTable::from_litellm_json(
823 r#"{"cheap":{"input_cost_per_token":0,"output_cost_per_token":0.001},
824 "pricey":{"input_cost_per_token":0,"output_cost_per_token":0.01}}"#,
825 );
826 let report = roll_up(&[s], &pricing, wednesday());
827 let forge = report.today.by_project.iter().find(|r| r.label == "forge").expect("forge row");
828 assert_eq!(forge.output, 200, "project tokens sum across its models");
829 approx(forge.cost_usd, 100.0 * 0.001 + 100.0 * 0.01);
830 }
831
832 #[test]
833 fn roll_up_sorts_rows_by_cost_desc() {
834 let s = summary_of(
835 "forge",
836 &[("lo", "2026-07-15", counts_out(10)), ("hi", "2026-07-15", counts_out(10))],
837 );
838 let pricing = PricingTable::from_litellm_json(
839 r#"{"lo":{"input_cost_per_token":0,"output_cost_per_token":0.001},
840 "hi":{"input_cost_per_token":0,"output_cost_per_token":0.05}}"#,
841 );
842 let report = roll_up(&[s], &pricing, wednesday());
843 assert_eq!(report.today.by_model.first().expect("a row").label, "hi", "priciest first");
844 }
845
846 #[test]
847 fn roll_up_separates_projects_and_orders_by_cost_desc() {
848 let cheap = summary_of("cheap-proj", &[("m", "2026-07-15", counts_out(100))]);
849 let pricey = summary_of("pricey-proj", &[("m", "2026-07-15", counts_out(1000))]);
850 let pricing = PricingTable::from_litellm_json(
851 r#"{"m":{"input_cost_per_token":0,"output_cost_per_token":0.01}}"#,
852 );
853 let report = roll_up(&[cheap, pricey], &pricing, wednesday());
854 let projects = &report.lifetime.by_project;
855 assert_eq!(projects.len(), 2, "two projects stay separate");
856 assert_eq!(projects[0].label, "pricey-proj", "sorted by cost descending");
857 assert_eq!(projects[1].label, "cheap-proj");
858 assert_eq!(projects.iter().find(|r| r.label == "cheap-proj").expect("cheap").output, 100);
859 assert_eq!(
860 projects.iter().find(|r| r.label == "pricey-proj").expect("pricey").output,
861 1000
862 );
863 }
864
865 #[test]
866 fn roll_up_window_edges_are_inclusive_and_keep_unpriced_tokens() {
867 let summary = summary_of(
869 "forge",
870 &[("m", "2026-07-13", counts_out(1)), ("m", "2026-07-01", counts_out(2))],
871 );
872 let report = roll_up(&[summary], &PricingTable::from_litellm_json("{}"), wednesday());
873 assert_eq!(report.week.total.output, 1, "a record on Monday's week_start is in the week");
874 assert_eq!(report.month.total.output, 3, "records on/after the 1st are in the month");
875 let row = &report.month.by_model[0];
877 assert_eq!(row.output, 3, "an unpriced row keeps its full tokens");
878 approx(row.cost_usd, 0.0);
879 }
880}