1use std::{
2 fs::File,
3 io::{BufRead, BufReader},
4 path::Path,
5};
6
7use chrono::{DateTime, Utc};
8use serde_json::Value;
9
10use crate::models::{TokenUsage, UNKNOWN, UsageEvent, UsageWarning};
11
12#[derive(Debug, Default)]
13pub struct ParsedSession {
14 pub events: Vec<UsageEvent>,
15 pub warnings: Vec<UsageWarning>,
16}
17
18#[derive(Debug, Clone)]
19struct SessionContext {
20 session_id: String,
21 thread: String,
22 project: String,
23 model: String,
24 effort: String,
25}
26
27impl Default for SessionContext {
28 fn default() -> Self {
29 Self {
30 session_id: UNKNOWN.to_string(),
31 thread: UNKNOWN.to_string(),
32 project: UNKNOWN.to_string(),
33 model: UNKNOWN.to_string(),
34 effort: UNKNOWN.to_string(),
35 }
36 }
37}
38
39pub fn parse_session_file(path: &Path) -> ParsedSession {
40 let mut parsed = ParsedSession::default();
41 let file = match File::open(path) {
42 Ok(file) => file,
43 Err(error) => {
44 parsed.warnings.push(UsageWarning {
45 file: path.display().to_string(),
46 line: None,
47 message: format!("failed to open file: {error}"),
48 });
49 return parsed;
50 }
51 };
52
53 let mut context = SessionContext::default();
54
55 for (index, line_result) in BufReader::new(file).lines().enumerate() {
56 let line_number = index + 1;
57 let line = match line_result {
58 Ok(line) => line,
59 Err(error) => {
60 parsed.warnings.push(UsageWarning {
61 file: path.display().to_string(),
62 line: Some(line_number),
63 message: format!("failed to read line: {error}"),
64 });
65 continue;
66 }
67 };
68
69 if line.trim().is_empty() {
70 continue;
71 }
72
73 let value: Value = match serde_json::from_str(&line) {
74 Ok(value) => value,
75 Err(error) => {
76 parsed.warnings.push(UsageWarning {
77 file: path.display().to_string(),
78 line: Some(line_number),
79 message: format!("malformed JSON: {error}"),
80 });
81 continue;
82 }
83 };
84
85 apply_context(&mut context, &value);
86
87 if let Some(event) = parse_token_event(path, &context, &value) {
88 parsed.events.push(event);
89 }
90 }
91
92 parsed
93}
94
95fn apply_context(context: &mut SessionContext, value: &Value) {
96 match text_at(value, &["type"]) {
97 Some("session_meta") => {
98 if let Some(session_id) = text_at(value, &["payload", "id"]) {
99 context.session_id = session_id.to_string();
100 if context.thread == UNKNOWN {
101 context.thread = session_id.to_string();
102 }
103 }
104 if let Some(thread) = text_at(value, &["payload", "thread_name"]) {
105 context.thread = thread.to_string();
106 }
107 if let Some(project) = text_at(value, &["payload", "cwd"]) {
108 context.project = project.to_string();
109 }
110 }
111 Some("turn_context") => {
112 if let Some(project) = text_at(value, &["payload", "cwd"]) {
113 context.project = project.to_string();
114 }
115 if let Some(model) = text_at(value, &["payload", "model"]) {
116 context.model = model.to_string();
117 }
118 if let Some(effort) = text_at(value, &["payload", "effort"]) {
119 context.effort = effort.to_string();
120 }
121 }
122 _ => {}
123 }
124}
125
126fn parse_token_event(path: &Path, context: &SessionContext, value: &Value) -> Option<UsageEvent> {
127 if text_at(value, &["type"]) != Some("event_msg")
128 || text_at(value, &["payload", "type"]) != Some("token_count")
129 {
130 return None;
131 }
132
133 let timestamp = text_at(value, &["timestamp"])
134 .and_then(parse_timestamp)
135 .unwrap_or_else(Utc::now);
136 let usage = usage_at(value, &["payload", "info", "last_token_usage"]);
137 let cumulative_total_tokens = number_at(
138 value,
139 &["payload", "info", "total_token_usage", "total_tokens"],
140 );
141 let context_window = number_at(value, &["payload", "info", "model_context_window"]);
142
143 Some(UsageEvent {
144 timestamp,
145 session_id: context.session_id.clone(),
146 thread: context.thread.clone(),
147 project: context.project.clone(),
148 model: context.model.clone(),
149 effort: context.effort.clone(),
150 context_window,
151 usage,
152 cumulative_total_tokens,
153 source_file: path.display().to_string(),
154 })
155}
156
157fn usage_at(value: &Value, path: &[&str]) -> TokenUsage {
158 let usage = value_at(value, path);
159 TokenUsage {
160 input_tokens: usage
161 .and_then(|v| number_at(v, &["input_tokens"]))
162 .unwrap_or(0),
163 cached_input_tokens: usage
164 .and_then(|v| number_at(v, &["cached_input_tokens"]))
165 .unwrap_or(0),
166 output_tokens: usage
167 .and_then(|v| number_at(v, &["output_tokens"]))
168 .unwrap_or(0),
169 reasoning_output_tokens: usage
170 .and_then(|v| number_at(v, &["reasoning_output_tokens"]))
171 .unwrap_or(0),
172 total_tokens: usage
173 .and_then(|v| number_at(v, &["total_tokens"]))
174 .unwrap_or(0),
175 }
176}
177
178fn parse_timestamp(text: &str) -> Option<DateTime<Utc>> {
179 DateTime::parse_from_rfc3339(text)
180 .ok()
181 .map(|timestamp| timestamp.with_timezone(&Utc))
182}
183
184fn text_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
185 value_at(value, path).and_then(Value::as_str)
186}
187
188fn number_at(value: &Value, path: &[&str]) -> Option<u64> {
189 value_at(value, path).and_then(Value::as_u64)
190}
191
192fn value_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
193 let mut current = value;
194 for segment in path {
195 current = current.get(*segment)?;
196 }
197 Some(current)
198}
199
200#[cfg(test)]
201mod tests {
202 use std::fs;
203
204 use tempfile::NamedTempFile;
205
206 use super::parse_session_file;
207
208 fn write_session(contents: &str) -> NamedTempFile {
209 let file = NamedTempFile::new().expect("create temp session");
210 fs::write(file.path(), contents).expect("write temp session");
211 file
212 }
213
214 #[test]
215 fn parses_token_count_events_with_context() {
216 let file = write_session(
217 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"}}
218{"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"}}
219{"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}}}
220"#,
221 );
222
223 let parsed = parse_session_file(file.path());
224
225 assert_eq!(parsed.events.len(), 1);
226 assert!(parsed.warnings.is_empty());
227
228 let event = &parsed.events[0];
229 assert_eq!(event.session_id, "session-1");
230 assert_eq!(event.thread, "Alpha Thread");
231 assert_eq!(event.project, "C:\\work\\alpha");
232 assert_eq!(event.model, "gpt-5");
233 assert_eq!(event.effort, "xhigh");
234 assert_eq!(event.context_window, Some(258400));
235 assert_eq!(event.usage.total_tokens, 12);
236 assert_eq!(event.usage.input_tokens, 10);
237 assert_eq!(event.usage.cached_input_tokens, 4);
238 assert_eq!(event.usage.output_tokens, 2);
239 assert_eq!(event.usage.reasoning_output_tokens, 1);
240 assert_eq!(event.cumulative_total_tokens, Some(120));
241 }
242
243 #[test]
244 fn skips_malformed_lines_with_warning() {
245 let file = write_session(
246 r#"{"timestamp":"2026-07-06T08:00:00.000Z","type":"session_meta","payload":{"id":"session-2","cwd":"C:\\work\\beta"}}
247not-json
248{"timestamp":"2026-07-06T08:02:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":8}}}}
249"#,
250 );
251
252 let parsed = parse_session_file(file.path());
253
254 assert_eq!(parsed.events.len(), 1);
255 assert_eq!(parsed.warnings.len(), 1);
256 assert!(parsed.warnings[0].message.contains("malformed JSON"));
257 assert_eq!(parsed.events[0].project, "C:\\work\\beta");
258 assert_eq!(parsed.events[0].usage.total_tokens, 8);
259 }
260}