codex-cost 0.1.0

Local web dashboard for inspecting Codex token usage from session logs.
Documentation
use std::{
    fs::File,
    io::{BufRead, BufReader},
    path::Path,
};

use chrono::{DateTime, Utc};
use serde_json::Value;

use crate::models::{TokenUsage, UNKNOWN, UsageEvent, UsageWarning};

#[derive(Debug, Default)]
pub struct ParsedSession {
    pub events: Vec<UsageEvent>,
    pub warnings: Vec<UsageWarning>,
}

#[derive(Debug, Clone)]
struct SessionContext {
    session_id: String,
    thread: String,
    project: String,
    model: String,
    effort: String,
}

impl Default for SessionContext {
    fn default() -> Self {
        Self {
            session_id: UNKNOWN.to_string(),
            thread: UNKNOWN.to_string(),
            project: UNKNOWN.to_string(),
            model: UNKNOWN.to_string(),
            effort: UNKNOWN.to_string(),
        }
    }
}

pub fn parse_session_file(path: &Path) -> ParsedSession {
    let mut parsed = ParsedSession::default();
    let file = match File::open(path) {
        Ok(file) => file,
        Err(error) => {
            parsed.warnings.push(UsageWarning {
                file: path.display().to_string(),
                line: None,
                message: format!("failed to open file: {error}"),
            });
            return parsed;
        }
    };

    let mut context = SessionContext::default();

    for (index, line_result) in BufReader::new(file).lines().enumerate() {
        let line_number = index + 1;
        let line = match line_result {
            Ok(line) => line,
            Err(error) => {
                parsed.warnings.push(UsageWarning {
                    file: path.display().to_string(),
                    line: Some(line_number),
                    message: format!("failed to read line: {error}"),
                });
                continue;
            }
        };

        if line.trim().is_empty() {
            continue;
        }

        let value: Value = match serde_json::from_str(&line) {
            Ok(value) => value,
            Err(error) => {
                parsed.warnings.push(UsageWarning {
                    file: path.display().to_string(),
                    line: Some(line_number),
                    message: format!("malformed JSON: {error}"),
                });
                continue;
            }
        };

        apply_context(&mut context, &value);

        if let Some(event) = parse_token_event(path, &context, &value) {
            parsed.events.push(event);
        }
    }

    parsed
}

fn apply_context(context: &mut SessionContext, value: &Value) {
    match text_at(value, &["type"]) {
        Some("session_meta") => {
            if let Some(session_id) = text_at(value, &["payload", "id"]) {
                context.session_id = session_id.to_string();
                if context.thread == UNKNOWN {
                    context.thread = session_id.to_string();
                }
            }
            if let Some(thread) = text_at(value, &["payload", "thread_name"]) {
                context.thread = thread.to_string();
            }
            if let Some(project) = text_at(value, &["payload", "cwd"]) {
                context.project = project.to_string();
            }
        }
        Some("turn_context") => {
            if let Some(project) = text_at(value, &["payload", "cwd"]) {
                context.project = project.to_string();
            }
            if let Some(model) = text_at(value, &["payload", "model"]) {
                context.model = model.to_string();
            }
            if let Some(effort) = text_at(value, &["payload", "effort"]) {
                context.effort = effort.to_string();
            }
        }
        _ => {}
    }
}

fn parse_token_event(path: &Path, context: &SessionContext, value: &Value) -> Option<UsageEvent> {
    if text_at(value, &["type"]) != Some("event_msg")
        || text_at(value, &["payload", "type"]) != Some("token_count")
    {
        return None;
    }

    let timestamp = text_at(value, &["timestamp"])
        .and_then(parse_timestamp)
        .unwrap_or_else(Utc::now);
    let usage = usage_at(value, &["payload", "info", "last_token_usage"]);
    let cumulative_total_tokens = number_at(
        value,
        &["payload", "info", "total_token_usage", "total_tokens"],
    );
    let context_window = number_at(value, &["payload", "info", "model_context_window"]);

    Some(UsageEvent {
        timestamp,
        session_id: context.session_id.clone(),
        thread: context.thread.clone(),
        project: context.project.clone(),
        model: context.model.clone(),
        effort: context.effort.clone(),
        context_window,
        usage,
        cumulative_total_tokens,
        source_file: path.display().to_string(),
    })
}

fn usage_at(value: &Value, path: &[&str]) -> TokenUsage {
    let usage = value_at(value, path);
    TokenUsage {
        input_tokens: usage
            .and_then(|v| number_at(v, &["input_tokens"]))
            .unwrap_or(0),
        cached_input_tokens: usage
            .and_then(|v| number_at(v, &["cached_input_tokens"]))
            .unwrap_or(0),
        output_tokens: usage
            .and_then(|v| number_at(v, &["output_tokens"]))
            .unwrap_or(0),
        reasoning_output_tokens: usage
            .and_then(|v| number_at(v, &["reasoning_output_tokens"]))
            .unwrap_or(0),
        total_tokens: usage
            .and_then(|v| number_at(v, &["total_tokens"]))
            .unwrap_or(0),
    }
}

fn parse_timestamp(text: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(text)
        .ok()
        .map(|timestamp| timestamp.with_timezone(&Utc))
}

fn text_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
    value_at(value, path).and_then(Value::as_str)
}

fn number_at(value: &Value, path: &[&str]) -> Option<u64> {
    value_at(value, path).and_then(Value::as_u64)
}

fn value_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
    let mut current = value;
    for segment in path {
        current = current.get(*segment)?;
    }
    Some(current)
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::NamedTempFile;

    use super::parse_session_file;

    fn write_session(contents: &str) -> NamedTempFile {
        let file = NamedTempFile::new().expect("create temp session");
        fs::write(file.path(), contents).expect("write temp session");
        file
    }

    #[test]
    fn parses_token_count_events_with_context() {
        let file = write_session(
            r#"{"timestamp":"2026-07-06T08:00:00.000Z","type":"session_meta","payload":{"id":"session-1","cwd":"C:\\work\\alpha","thread_name":"Alpha Thread","model_provider":"openai"}}
{"timestamp":"2026-07-06T08:01:00.000Z","type":"turn_context","payload":{"cwd":"C:\\work\\alpha","workspace_roots":["C:\\work\\alpha"],"model":"gpt-5","effort":"xhigh"}}
{"timestamp":"2026-07-06T08:02:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":40,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":120},"last_token_usage":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":2,"reasoning_output_tokens":1,"total_tokens":12},"model_context_window":258400}}}
"#,
        );

        let parsed = parse_session_file(file.path());

        assert_eq!(parsed.events.len(), 1);
        assert!(parsed.warnings.is_empty());

        let event = &parsed.events[0];
        assert_eq!(event.session_id, "session-1");
        assert_eq!(event.thread, "Alpha Thread");
        assert_eq!(event.project, "C:\\work\\alpha");
        assert_eq!(event.model, "gpt-5");
        assert_eq!(event.effort, "xhigh");
        assert_eq!(event.context_window, Some(258400));
        assert_eq!(event.usage.total_tokens, 12);
        assert_eq!(event.usage.input_tokens, 10);
        assert_eq!(event.usage.cached_input_tokens, 4);
        assert_eq!(event.usage.output_tokens, 2);
        assert_eq!(event.usage.reasoning_output_tokens, 1);
        assert_eq!(event.cumulative_total_tokens, Some(120));
    }

    #[test]
    fn skips_malformed_lines_with_warning() {
        let file = write_session(
            r#"{"timestamp":"2026-07-06T08:00:00.000Z","type":"session_meta","payload":{"id":"session-2","cwd":"C:\\work\\beta"}}
not-json
{"timestamp":"2026-07-06T08:02:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":8}}}}
"#,
        );

        let parsed = parse_session_file(file.path());

        assert_eq!(parsed.events.len(), 1);
        assert_eq!(parsed.warnings.len(), 1);
        assert!(parsed.warnings[0].message.contains("malformed JSON"));
        assert_eq!(parsed.events[0].project, "C:\\work\\beta");
        assert_eq!(parsed.events[0].usage.total_tokens, 8);
    }
}