1use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::Deserialize;
13use std::fs::File;
14use std::io::{BufRead, BufReader};
15use std::path::{Path, PathBuf};
16use uuid::Uuid;
17
18use crate::storage::models::{Message, MessageContent, MessageRole, Session};
19
20use super::{Watcher, WatcherInfo};
21
22pub struct CodexWatcher;
27
28impl Watcher for CodexWatcher {
29 fn info(&self) -> WatcherInfo {
30 WatcherInfo {
31 name: "codex",
32 description: "OpenAI Codex CLI",
33 default_paths: vec![codex_sessions_dir()],
34 }
35 }
36
37 fn is_available(&self) -> bool {
38 codex_sessions_dir().exists()
39 }
40
41 fn find_sources(&self) -> Result<Vec<PathBuf>> {
42 find_codex_session_files()
43 }
44
45 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
46 let parsed = parse_codex_session_file(path)?;
47 if parsed.messages.is_empty() {
48 return Ok(vec![]);
49 }
50 let (session, messages) = parsed.to_storage_models();
51 Ok(vec![(session, messages)])
52 }
53
54 fn watch_paths(&self) -> Vec<PathBuf> {
55 vec![codex_sessions_dir()]
56 }
57}
58
59fn codex_sessions_dir() -> PathBuf {
63 dirs::home_dir()
64 .unwrap_or_else(|| PathBuf::from("."))
65 .join(".codex")
66 .join("sessions")
67}
68
69#[derive(Debug, Deserialize)]
71struct RawSessionMeta {
72 id: String,
73 #[allow(dead_code)]
74 timestamp: String,
75 cwd: String,
76 #[serde(default)]
77 cli_version: Option<String>,
78 #[serde(default)]
79 model_provider: Option<String>,
80 #[serde(default)]
81 git: Option<RawGitInfo>,
82}
83
84#[derive(Debug, Deserialize)]
86struct RawGitInfo {
87 #[serde(default)]
88 branch: Option<String>,
89}
90
91#[derive(Debug, Deserialize)]
93struct RawEntry {
94 timestamp: String,
95 #[serde(rename = "type")]
96 entry_type: String,
97 #[serde(default)]
98 payload: Option<serde_json::Value>,
99}
100
101#[derive(Debug, Deserialize)]
103struct RawResponseItem {
104 #[serde(rename = "type")]
105 item_type: Option<String>,
106 role: Option<String>,
107 #[serde(default)]
108 content: Vec<RawContentItem>,
109}
110
111#[derive(Debug, Deserialize)]
113struct RawContentItem {
114 #[serde(rename = "type")]
115 content_type: String,
116 #[serde(default)]
117 text: Option<String>,
118}
119
120pub fn parse_codex_session_file(path: &Path) -> Result<ParsedCodexSession> {
129 let file = File::open(path).context("Failed to open Codex session file")?;
130 let reader = BufReader::new(file);
131
132 let mut session_id: Option<String> = None;
133 let mut cli_version: Option<String> = None;
134 let mut cwd: Option<String> = None;
135 let mut git_branch: Option<String> = None;
136 let mut model_provider: Option<String> = None;
137 let mut messages: Vec<ParsedCodexMessage> = Vec::new();
138
139 for (line_num, line) in reader.lines().enumerate() {
140 let line = match line {
141 Ok(l) => l,
142 Err(e) => {
143 tracing::debug!("Failed to read line {}: {}", line_num + 1, e);
144 continue;
145 }
146 };
147
148 if line.trim().is_empty() {
149 continue;
150 }
151
152 let entry: RawEntry = match serde_json::from_str(&line) {
153 Ok(e) => e,
154 Err(e) => {
155 tracing::debug!("Skipping unparseable line {}: {}", line_num + 1, e);
156 continue;
157 }
158 };
159
160 match entry.entry_type.as_str() {
161 "session_meta" => {
162 if let Some(payload) = entry.payload {
163 if let Ok(meta) = serde_json::from_value::<RawSessionMeta>(payload) {
164 if session_id.is_none() {
165 session_id = Some(meta.id);
166 }
167 if cli_version.is_none() {
168 cli_version = meta.cli_version;
169 }
170 if cwd.is_none() {
171 cwd = Some(meta.cwd);
172 }
173 if model_provider.is_none() {
174 model_provider = meta.model_provider;
175 }
176 if git_branch.is_none() {
177 git_branch = meta.git.and_then(|g| g.branch);
178 }
179 }
180 }
181 }
182 "response_item" => {
183 if let Some(payload) = entry.payload {
184 if let Ok(item) = serde_json::from_value::<RawResponseItem>(payload) {
185 if item.item_type.as_deref() != Some("message") {
187 continue;
188 }
189
190 let role = match item.role.as_deref() {
191 Some("user") => MessageRole::User,
192 Some("assistant") => MessageRole::Assistant,
193 Some("system") => MessageRole::System,
194 _ => continue,
195 };
196
197 let text: String = item
199 .content
200 .iter()
201 .filter_map(|c| {
202 if c.content_type == "input_text" || c.content_type == "text" {
203 c.text.clone()
204 } else {
205 None
206 }
207 })
208 .collect::<Vec<_>>()
209 .join("\n");
210
211 if text.trim().is_empty() {
212 continue;
213 }
214
215 let timestamp = DateTime::parse_from_rfc3339(&entry.timestamp)
216 .map(|t| t.with_timezone(&Utc))
217 .unwrap_or_else(|_| Utc::now());
218
219 messages.push(ParsedCodexMessage {
220 timestamp,
221 role,
222 content: text,
223 });
224 }
225 }
226 }
227 _ => {
228 }
230 }
231 }
232
233 Ok(ParsedCodexSession {
234 session_id: session_id.unwrap_or_else(|| {
235 path.file_stem()
236 .and_then(|s| s.to_str())
237 .unwrap_or("unknown")
238 .to_string()
239 }),
240 cli_version,
241 cwd: cwd.unwrap_or_else(|| ".".to_string()),
242 git_branch,
243 model_provider,
244 messages,
245 source_path: path.to_string_lossy().to_string(),
246 })
247}
248
249#[derive(Debug)]
251pub struct ParsedCodexSession {
252 pub session_id: String,
253 pub cli_version: Option<String>,
254 pub cwd: String,
255 pub git_branch: Option<String>,
256 pub model_provider: Option<String>,
257 pub messages: Vec<ParsedCodexMessage>,
258 pub source_path: String,
259}
260
261impl ParsedCodexSession {
262 pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
264 let session_uuid = Uuid::parse_str(&self.session_id).unwrap_or_else(|_| Uuid::new_v4());
265
266 let started_at = self
267 .messages
268 .first()
269 .map(|m| m.timestamp)
270 .unwrap_or_else(Utc::now);
271
272 let ended_at = self.messages.last().map(|m| m.timestamp);
273
274 let session = Session {
275 id: session_uuid,
276 tool: "codex".to_string(),
277 tool_version: self.cli_version.clone(),
278 started_at,
279 ended_at,
280 model: self.model_provider.clone(),
281 working_directory: self.cwd.clone(),
282 git_branch: self.git_branch.clone(),
283 source_path: Some(self.source_path.clone()),
284 message_count: self.messages.len() as i32,
285 };
286
287 let messages: Vec<Message> = self
288 .messages
289 .iter()
290 .enumerate()
291 .map(|(idx, m)| Message {
292 id: Uuid::new_v4(),
293 session_id: session_uuid,
294 parent_id: None,
295 index: idx as i32,
296 timestamp: m.timestamp,
297 role: m.role.clone(),
298 content: MessageContent::Text(m.content.clone()),
299 model: self.model_provider.clone(),
300 git_branch: self.git_branch.clone(),
301 cwd: Some(self.cwd.clone()),
302 })
303 .collect();
304
305 (session, messages)
306 }
307}
308
309#[derive(Debug)]
311pub struct ParsedCodexMessage {
312 pub timestamp: DateTime<Utc>,
313 pub role: MessageRole,
314 pub content: String,
315}
316
317pub fn find_codex_session_files() -> Result<Vec<PathBuf>> {
321 let sessions_dir = codex_sessions_dir();
322
323 if !sessions_dir.exists() {
324 return Ok(Vec::new());
325 }
326
327 let mut files = Vec::new();
328
329 for year_entry in std::fs::read_dir(&sessions_dir)? {
331 let year_entry = year_entry?;
332 let year_path = year_entry.path();
333 if !year_path.is_dir() {
334 continue;
335 }
336
337 for month_entry in std::fs::read_dir(&year_path)? {
338 let month_entry = month_entry?;
339 let month_path = month_entry.path();
340 if !month_path.is_dir() {
341 continue;
342 }
343
344 for day_entry in std::fs::read_dir(&month_path)? {
345 let day_entry = day_entry?;
346 let day_path = day_entry.path();
347 if !day_path.is_dir() {
348 continue;
349 }
350
351 for file_entry in std::fs::read_dir(&day_path)? {
352 let file_entry = file_entry?;
353 let file_path = file_entry.path();
354
355 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
356 if name.starts_with("rollout-") && name.ends_with(".jsonl") {
357 files.push(file_path);
358 }
359 }
360 }
361 }
362 }
363 }
364
365 Ok(files)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use std::io::Write;
372 use tempfile::NamedTempFile;
373
374 fn create_temp_session_file(lines: &[&str]) -> NamedTempFile {
376 let mut file = NamedTempFile::new().expect("Failed to create temp file");
377 for line in lines {
378 writeln!(file, "{line}").expect("Failed to write line");
379 }
380 file.flush().expect("Failed to flush");
381 file
382 }
383
384 fn make_session_meta(session_id: &str, cwd: &str, version: &str) -> String {
386 format!(
387 r#"{{"timestamp":"2025-12-18T22:53:29.406Z","type":"session_meta","payload":{{"id":"{session_id}","timestamp":"2025-12-18T22:53:29.377Z","cwd":"{cwd}","originator":"codex_cli_rs","cli_version":"{version}","model_provider":"openai","git":{{"branch":"main"}}}}}}"#
388 )
389 }
390
391 fn make_user_message(content: &str) -> String {
393 format!(
394 r#"{{"timestamp":"2025-12-18T22:54:00.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{content}"}}]}}}}"#
395 )
396 }
397
398 fn make_assistant_message(content: &str) -> String {
400 format!(
401 r#"{{"timestamp":"2025-12-18T22:55:00.000Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"text","text":"{content}"}}]}}}}"#
402 )
403 }
404
405 #[test]
406 fn test_watcher_info() {
407 let watcher = CodexWatcher;
408 let info = watcher.info();
409
410 assert_eq!(info.name, "codex");
411 assert_eq!(info.description, "OpenAI Codex CLI");
412 assert!(!info.default_paths.is_empty());
413 assert!(info.default_paths[0].to_string_lossy().contains(".codex"));
414 }
415
416 #[test]
417 fn test_watcher_watch_paths() {
418 let watcher = CodexWatcher;
419 let paths = watcher.watch_paths();
420
421 assert!(!paths.is_empty());
422 assert!(paths[0].to_string_lossy().contains(".codex"));
423 }
424
425 #[test]
426 fn test_parse_session_meta() {
427 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
428 let meta_line = make_session_meta(session_id, "/Users/test/project", "0.63.0");
429
430 let file = create_temp_session_file(&[&meta_line]);
431 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
432
433 assert_eq!(parsed.session_id, session_id);
434 assert_eq!(parsed.cli_version, Some("0.63.0".to_string()));
435 assert_eq!(parsed.cwd, "/Users/test/project");
436 assert_eq!(parsed.model_provider, Some("openai".to_string()));
437 assert_eq!(parsed.git_branch, Some("main".to_string()));
438 }
439
440 #[test]
441 fn test_parse_user_message() {
442 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
443 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
444 let user_line = make_user_message("Hello, can you help me?");
445
446 let file = create_temp_session_file(&[&meta_line, &user_line]);
447 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
448
449 assert_eq!(parsed.messages.len(), 1);
450 assert_eq!(parsed.messages[0].role, MessageRole::User);
451 assert_eq!(parsed.messages[0].content, "Hello, can you help me?");
452 }
453
454 #[test]
455 fn test_parse_assistant_message() {
456 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
457 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
458 let assistant_line = make_assistant_message("Sure, I can help!");
459
460 let file = create_temp_session_file(&[&meta_line, &assistant_line]);
461 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
462
463 assert_eq!(parsed.messages.len(), 1);
464 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
465 assert_eq!(parsed.messages[0].content, "Sure, I can help!");
466 }
467
468 #[test]
469 fn test_parse_conversation() {
470 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
471 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
472 let user_line = make_user_message("Hello");
473 let assistant_line = make_assistant_message("Hi there!");
474
475 let file = create_temp_session_file(&[&meta_line, &user_line, &assistant_line]);
476 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
477
478 assert_eq!(parsed.messages.len(), 2);
479 assert_eq!(parsed.messages[0].role, MessageRole::User);
480 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
481 }
482
483 #[test]
484 fn test_to_storage_models() {
485 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
486 let meta_line = make_session_meta(session_id, "/test/project", "0.63.0");
487 let user_line = make_user_message("Hello");
488 let assistant_line = make_assistant_message("Hi!");
489
490 let file = create_temp_session_file(&[&meta_line, &user_line, &assistant_line]);
491 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
492 let (session, messages) = parsed.to_storage_models();
493
494 assert_eq!(session.tool, "codex");
495 assert_eq!(session.tool_version, Some("0.63.0".to_string()));
496 assert_eq!(session.working_directory, "/test/project");
497 assert_eq!(session.git_branch, Some("main".to_string()));
498 assert_eq!(session.model, Some("openai".to_string()));
499 assert_eq!(session.message_count, 2);
500
501 assert_eq!(messages.len(), 2);
502 assert_eq!(messages[0].role, MessageRole::User);
503 assert_eq!(messages[1].role, MessageRole::Assistant);
504 assert_eq!(messages[0].index, 0);
505 assert_eq!(messages[1].index, 1);
506 }
507
508 #[test]
509 fn test_empty_lines_skipped() {
510 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
511 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
512 let user_line = make_user_message("Hello");
513
514 let file = create_temp_session_file(&["", &meta_line, " ", &user_line, ""]);
515 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
516
517 assert_eq!(parsed.messages.len(), 1);
518 }
519
520 #[test]
521 fn test_invalid_json_skipped() {
522 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
523 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
524 let user_line = make_user_message("Hello");
525
526 let file =
527 create_temp_session_file(&["invalid json", &meta_line, "{not valid", &user_line]);
528 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
529
530 assert_eq!(parsed.messages.len(), 1);
531 assert_eq!(parsed.session_id, session_id);
532 }
533
534 #[test]
535 fn test_non_message_response_items_skipped() {
536 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
537 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
538 let other_item = r#"{"timestamp":"2025-12-18T22:54:00.000Z","type":"response_item","payload":{"type":"function_call","name":"test"}}"#;
540 let user_line = make_user_message("Hello");
541
542 let file = create_temp_session_file(&[&meta_line, other_item, &user_line]);
543 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
544
545 assert_eq!(parsed.messages.len(), 1);
546 assert_eq!(parsed.messages[0].role, MessageRole::User);
547 }
548
549 #[test]
550 fn test_empty_content_skipped() {
551 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
552 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
553 let empty_content = r#"{"timestamp":"2025-12-18T22:54:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[]}}"#;
554 let user_line = make_user_message("Hello");
555
556 let file = create_temp_session_file(&[&meta_line, empty_content, &user_line]);
557 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
558
559 assert_eq!(parsed.messages.len(), 1);
560 }
561
562 #[test]
563 fn test_find_session_files_returns_empty_when_missing() {
564 let result = find_codex_session_files();
565 assert!(result.is_ok());
566 }
568
569 #[test]
570 fn test_watcher_parse_source() {
571 let watcher = CodexWatcher;
572 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
573 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
574 let user_line = make_user_message("Hello");
575
576 let file = create_temp_session_file(&[&meta_line, &user_line]);
577 let result = watcher
578 .parse_source(file.path())
579 .expect("Should parse successfully");
580
581 assert_eq!(result.len(), 1);
582 let (session, messages) = &result[0];
583 assert_eq!(session.tool, "codex");
584 assert_eq!(messages.len(), 1);
585 }
586
587 #[test]
588 fn test_watcher_parse_source_empty_session() {
589 let watcher = CodexWatcher;
590 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
591 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
592
593 let file = create_temp_session_file(&[&meta_line]);
595 let result = watcher
596 .parse_source(file.path())
597 .expect("Should parse successfully");
598
599 assert!(result.is_empty());
600 }
601
602 #[test]
603 fn test_session_id_fallback_to_filename() {
604 let user_line = make_user_message("Hello");
606 let file = create_temp_session_file(&[&user_line]);
607 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
608
609 assert!(!parsed.session_id.is_empty());
611 }
612
613 #[test]
614 fn test_uuid_session_id_parsing() {
615 let session_id = "019b33ab-179f-7802-88a6-16557b4b7603";
616 let meta_line = make_session_meta(session_id, "/test", "0.63.0");
617 let user_line = make_user_message("Hello");
618
619 let file = create_temp_session_file(&[&meta_line, &user_line]);
620 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
621 let (session, _) = parsed.to_storage_models();
622
623 assert_eq!(session.id.to_string(), session_id);
625 }
626
627 #[test]
628 fn test_invalid_uuid_generates_new() {
629 let meta_line = r#"{"timestamp":"2025-12-18T22:53:29.406Z","type":"session_meta","payload":{"id":"not-a-uuid","timestamp":"2025-12-18T22:53:29.377Z","cwd":"/test","cli_version":"0.63.0"}}"#;
630 let user_line = make_user_message("Hello");
631
632 let file = create_temp_session_file(&[meta_line, &user_line]);
633 let parsed = parse_codex_session_file(file.path()).expect("Failed to parse");
634 let (session, _) = parsed.to_storage_models();
635
636 assert!(!session.id.is_nil());
638 }
639}