codex-cost 0.1.0

Local web dashboard for inspecting Codex token usage from session logs.
Documentation
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"));
    }
}