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);
}
}