use std::{
cmp::Reverse,
collections::{BTreeMap, HashMap},
};
use chrono::{DateTime, Duration, Local, Utc};
use crate::models::{
DimensionDistributions, DistributionPoint, TimelinePoint, Totals, UNKNOWN, UsageEvent,
UsageSummary, UsageWarning,
};
pub fn build_summary(
mut events: Vec<UsageEvent>,
warnings: Vec<UsageWarning>,
session_count: usize,
now: DateTime<Utc>,
) -> UsageSummary {
let now_date = now.with_timezone(&Local).date_naive();
let last_7_days_start = now_date - Duration::days(6);
let mut totals = Totals {
event_count: events.len(),
session_count,
..Totals::default()
};
let mut timeline = BTreeMap::<String, (u64, usize)>::new();
for event in &events {
let tokens = event.usage.total_tokens;
let event_date = event.timestamp.with_timezone(&Local).date_naive();
totals.all_time_tokens += tokens;
totals.input_tokens += event.usage.input_tokens;
totals.cached_input_tokens += event.usage.cached_input_tokens;
totals.output_tokens += event.usage.output_tokens;
totals.reasoning_output_tokens += event.usage.reasoning_output_tokens;
if event_date == now_date {
totals.today_tokens += tokens;
}
if event_date >= last_7_days_start && event_date <= now_date {
totals.last_7_days_tokens += tokens;
}
let bucket = event_date.to_string();
let entry = timeline.entry(bucket).or_insert((0, 0));
entry.0 += tokens;
entry.1 += 1;
}
events.sort_by_key(|event| Reverse(event.timestamp));
UsageSummary {
generated_at: now,
totals,
timeline: timeline
.into_iter()
.map(|(bucket, (total_tokens, event_count))| TimelinePoint {
bucket,
total_tokens,
event_count,
})
.collect(),
distributions: DimensionDistributions {
projects: distribution_by(&events, |event| event.project.clone()),
models: distribution_by(&events, |event| event.model.clone()),
threads: distribution_by(&events, |event| event.thread.clone()),
efforts: distribution_by(&events, |event| event.effort.clone()),
context_windows: distribution_by(&events, |event| {
event
.context_window
.map(|window| window.to_string())
.unwrap_or_else(|| UNKNOWN.to_string())
}),
},
events,
warnings,
}
}
fn distribution_by(
events: &[UsageEvent],
label_for: impl Fn(&UsageEvent) -> String,
) -> Vec<DistributionPoint> {
let mut totals = HashMap::<String, (u64, usize)>::new();
for event in events {
let label = normalize_label(label_for(event));
let entry = totals.entry(label).or_insert((0, 0));
entry.0 += event.usage.total_tokens;
entry.1 += 1;
}
let mut points = totals
.into_iter()
.map(|(label, (total_tokens, event_count))| DistributionPoint {
label,
total_tokens,
event_count,
})
.collect::<Vec<_>>();
points.sort_by(|left, right| {
right
.total_tokens
.cmp(&left.total_tokens)
.then_with(|| right.event_count.cmp(&left.event_count))
.then_with(|| left.label.cmp(&right.label))
});
points
}
fn normalize_label(label: String) -> String {
if label.trim().is_empty() {
UNKNOWN.to_string()
} else {
label
}
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use super::build_summary;
use crate::models::{TokenUsage, UsageEvent};
fn at(timestamp: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(timestamp)
.expect("valid timestamp")
.with_timezone(&Utc)
}
fn event(
timestamp: &str,
project: &str,
model: &str,
thread: &str,
effort: &str,
context_window: Option<u64>,
total_tokens: u64,
) -> UsageEvent {
UsageEvent {
timestamp: at(timestamp),
session_id: format!("session-{thread}"),
thread: thread.to_string(),
project: project.to_string(),
model: model.to_string(),
effort: effort.to_string(),
context_window,
usage: TokenUsage {
input_tokens: total_tokens / 2,
cached_input_tokens: 1,
output_tokens: total_tokens / 4,
reasoning_output_tokens: 2,
total_tokens,
},
cumulative_total_tokens: Some(total_tokens * 10),
source_file: "fixture.jsonl".to_string(),
}
}
#[test]
fn aggregates_totals_time_windows_and_token_breakdown() {
let events = vec![
event(
"2026-07-06T08:00:00Z",
"alpha",
"gpt-5",
"one",
"xhigh",
Some(200),
10,
),
event(
"2026-07-05T08:00:00Z",
"alpha",
"gpt-5",
"two",
"high",
Some(200),
20,
),
event(
"2026-06-28T08:00:00Z",
"beta",
"gpt-4.1",
"three",
"medium",
None,
30,
),
];
let summary = build_summary(events, Vec::new(), 3, at("2026-07-06T12:00:00Z"));
assert_eq!(summary.totals.all_time_tokens, 60);
assert_eq!(summary.totals.today_tokens, 10);
assert_eq!(summary.totals.last_7_days_tokens, 30);
assert_eq!(summary.totals.event_count, 3);
assert_eq!(summary.totals.session_count, 3);
assert_eq!(summary.totals.input_tokens, 30);
assert_eq!(summary.totals.cached_input_tokens, 3);
assert_eq!(summary.totals.output_tokens, 14);
assert_eq!(summary.totals.reasoning_output_tokens, 6);
}
#[test]
fn builds_sorted_distributions_and_timeline() {
let events = vec![
event(
"2026-07-06T08:00:00Z",
"alpha",
"gpt-5",
"one",
"xhigh",
Some(200),
10,
),
event(
"2026-07-05T08:00:00Z",
"alpha",
"gpt-5",
"two",
"high",
Some(200),
20,
),
event(
"2026-07-05T09:00:00Z",
"beta",
"gpt-4.1",
"one",
"xhigh",
None,
30,
),
];
let summary = build_summary(events, Vec::new(), 2, at("2026-07-06T12:00:00Z"));
assert_eq!(summary.distributions.projects[0].label, "alpha");
assert_eq!(summary.distributions.projects[0].total_tokens, 30);
assert_eq!(summary.distributions.projects[1].label, "beta");
assert_eq!(summary.distributions.projects[1].total_tokens, 30);
assert_eq!(summary.distributions.models[0].label, "gpt-5");
assert_eq!(summary.distributions.models[0].total_tokens, 30);
assert_eq!(summary.distributions.threads[0].label, "one");
assert_eq!(summary.distributions.threads[0].total_tokens, 40);
assert_eq!(summary.distributions.efforts[0].label, "xhigh");
assert_eq!(summary.distributions.efforts[0].total_tokens, 40);
assert_eq!(summary.distributions.context_windows[0].label, "200");
assert_eq!(summary.distributions.context_windows[0].total_tokens, 30);
assert_eq!(summary.distributions.context_windows[1].label, "Unknown");
assert_eq!(summary.distributions.context_windows[1].total_tokens, 30);
assert_eq!(summary.timeline.len(), 2);
assert_eq!(summary.timeline[0].bucket, "2026-07-05");
assert_eq!(summary.timeline[0].total_tokens, 50);
assert_eq!(summary.timeline[1].bucket, "2026-07-06");
assert_eq!(summary.timeline[1].total_tokens, 10);
assert_eq!(summary.events[0].timestamp, at("2026-07-06T08:00:00Z"));
}
}