Skip to main content

codex_cost/
parser.rs

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}