1use std::path::{Path, PathBuf};
62
63use crate::error::{Error, Result};
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SessionFile {
68 pub path: PathBuf,
70 pub id: String,
74 pub date: (u16, u8, u8),
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct GitMeta {
82 pub commit_hash: Option<String>,
84 pub branch: Option<String>,
86 pub repository_url: Option<String>,
88}
89
90#[derive(Debug, Clone, Default, PartialEq)]
95#[non_exhaustive]
96pub struct SessionMeta {
97 pub id: Option<String>,
99 pub timestamp: Option<String>,
101 pub cwd: Option<PathBuf>,
103 pub cli_version: Option<String>,
105 pub originator: Option<String>,
107 pub source: Option<String>,
109 pub git: Option<GitMeta>,
111 pub raw: serde_json::Value,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct SessionEntry {
118 pub timestamp: Option<String>,
120 pub entry_type: Option<String>,
125 pub payload: serde_json::Value,
127}
128
129impl SessionEntry {
130 #[must_use]
135 pub fn payload_type(&self) -> Option<&str> {
136 self.payload.get("type")?.as_str()
137 }
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct SessionLog {
143 pub path: PathBuf,
145 pub meta: Option<SessionMeta>,
147 pub entries: Vec<SessionEntry>,
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct SessionQuery {
154 after: Option<(u16, u8, u8)>,
155 before: Option<(u16, u8, u8)>,
156 cwd: Option<PathBuf>,
157}
158
159impl SessionQuery {
160 #[must_use]
162 pub fn new() -> Self {
163 Self::default()
164 }
165
166 #[must_use]
168 pub fn after(mut self, year: u16, month: u8, day: u8) -> Self {
169 self.after = Some((year, month, day));
170 self
171 }
172
173 #[must_use]
175 pub fn before(mut self, year: u16, month: u8, day: u8) -> Self {
176 self.before = Some((year, month, day));
177 self
178 }
179
180 #[must_use]
186 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
187 self.cwd = Some(cwd.into());
188 self
189 }
190
191 fn matches_date(&self, date: (u16, u8, u8)) -> bool {
192 self.after.is_none_or(|after| date >= after)
193 && self.before.is_none_or(|before| date <= before)
194 }
195}
196
197pub fn list(query: &SessionQuery) -> Result<Vec<SessionFile>> {
201 let home = crate::codex_home::resolve(&|key| std::env::var(key).ok());
202 list_in(home, query)
203}
204
205pub fn list_in(codex_home: impl AsRef<Path>, query: &SessionQuery) -> Result<Vec<SessionFile>> {
210 let root = codex_home.as_ref().join("sessions");
211 let mut found = Vec::new();
212
213 for (date, day_dir) in date_dirs(&root) {
214 if !query.matches_date(date) {
215 continue;
216 }
217 let Ok(entries) = std::fs::read_dir(&day_dir) else {
218 continue;
219 };
220 for entry in entries.filter_map(std::result::Result::ok) {
221 let path = entry.path();
222 let Some(id) = session_id_from_path(&path) else {
223 continue;
224 };
225 if let Some(wanted) = &query.cwd
226 && !session_ran_in(&path, wanted)
227 {
228 continue;
229 }
230 found.push(SessionFile { path, id, date });
231 }
232 }
233
234 found.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| b.path.cmp(&a.path)));
237 Ok(found)
238}
239
240pub fn read(path: impl AsRef<Path>) -> Result<SessionLog> {
242 let path = path.as_ref();
243 let contents = std::fs::read_to_string(path).map_err(|e| Error::Io {
244 message: format!("failed to read {}: {e}", path.display()),
245 source: e,
246 working_dir: None,
247 })?;
248
249 let mut meta = None;
250 let mut entries = Vec::new();
251
252 for line in contents.lines() {
253 let line = line.trim();
254 if line.is_empty() {
255 continue;
256 }
257 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
261 continue;
262 };
263
264 match envelope(&value) {
265 Some((timestamp, entry_type, payload)) => {
266 if entry_type == "session_meta" && meta.is_none() {
267 meta = Some(parse_meta(payload));
268 continue;
269 }
270 entries.push(SessionEntry {
271 timestamp: timestamp.map(str::to_string),
272 entry_type: Some(entry_type.to_string()),
273 payload: payload.clone(),
274 });
275 }
276 None => {
277 if meta.is_none() && value.get("id").is_some() && value.get("type").is_none() {
280 meta = Some(parse_meta(&value));
281 continue;
282 }
283 entries.push(SessionEntry {
284 timestamp: value
285 .get("timestamp")
286 .and_then(|v| v.as_str())
287 .map(str::to_string),
288 entry_type: None,
289 payload: value,
290 });
291 }
292 }
293 }
294
295 Ok(SessionLog {
296 path: path.to_path_buf(),
297 meta,
298 entries,
299 })
300}
301
302fn envelope(value: &serde_json::Value) -> Option<(Option<&str>, &str, &serde_json::Value)> {
305 let payload = value.get("payload")?;
306 let entry_type = value.get("type")?.as_str()?;
307 Some((
308 value.get("timestamp").and_then(serde_json::Value::as_str),
309 entry_type,
310 payload,
311 ))
312}
313
314fn parse_meta(value: &serde_json::Value) -> SessionMeta {
315 let string = |key: &str| {
316 value
317 .get(key)
318 .and_then(serde_json::Value::as_str)
319 .map(str::to_string)
320 };
321 SessionMeta {
322 id: string("id").or_else(|| string("session_id")),
323 timestamp: string("timestamp"),
324 cwd: string("cwd").map(PathBuf::from),
325 cli_version: string("cli_version"),
326 originator: string("originator"),
327 source: string("source"),
328 git: value.get("git").and_then(|git| {
329 let get = |key: &str| {
330 git.get(key)
331 .and_then(serde_json::Value::as_str)
332 .map(str::to_string)
333 };
334 let meta = GitMeta {
335 commit_hash: get("commit_hash"),
336 branch: get("branch"),
337 repository_url: get("repository_url"),
338 };
339 (meta != GitMeta::default()).then_some(meta)
340 }),
341 raw: value.clone(),
342 }
343}
344
345fn date_dirs(root: &Path) -> Vec<((u16, u8, u8), PathBuf)> {
347 let mut out = Vec::new();
348 for year in numeric_children(root) {
349 for month in numeric_children(&year.1) {
350 for day in numeric_children(&month.1) {
351 out.push(((year.0 as u16, month.0 as u8, day.0 as u8), day.1));
352 }
353 }
354 }
355 out
356}
357
358fn numeric_children(dir: &Path) -> Vec<(u32, PathBuf)> {
360 let Ok(entries) = std::fs::read_dir(dir) else {
361 return Vec::new();
362 };
363 entries
364 .filter_map(std::result::Result::ok)
365 .filter_map(|entry| {
366 let name = entry.file_name().into_string().ok()?;
367 let value = name.parse::<u32>().ok()?;
368 entry.path().is_dir().then(|| (value, entry.path()))
369 })
370 .collect()
371}
372
373fn session_id_from_path(path: &Path) -> Option<String> {
378 let name = path.file_name()?.to_str()?;
379 let stem = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
380 let parts: Vec<&str> = stem.split('-').collect();
381 if parts.len() < 5 {
382 return None;
383 }
384 Some(parts[parts.len() - 5..].join("-"))
385}
386
387fn session_ran_in(path: &Path, wanted: &Path) -> bool {
389 let Ok(contents) = std::fs::read_to_string(path) else {
390 return false;
391 };
392 let Some(first) = contents.lines().find(|line| !line.trim().is_empty()) else {
393 return false;
394 };
395 let Ok(value) = serde_json::from_str::<serde_json::Value>(first) else {
396 return false;
397 };
398 let meta = value.get("payload").unwrap_or(&value);
399 meta.get("cwd").and_then(serde_json::Value::as_str) == wanted.to_str()
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn temp_home(label: &str) -> PathBuf {
407 let dir = std::env::temp_dir().join(format!(
408 "codex-wrapper-history-{}-{label}",
409 std::process::id()
410 ));
411 let _ = std::fs::remove_dir_all(&dir);
412 dir
413 }
414
415 fn write_session(home: &Path, date: (u16, u8, u8), name: &str, lines: &[&str]) -> PathBuf {
416 let dir = home
417 .join("sessions")
418 .join(format!("{:04}", date.0))
419 .join(format!("{:02}", date.1))
420 .join(format!("{:02}", date.2));
421 std::fs::create_dir_all(&dir).unwrap();
422 let path = dir.join(name);
423 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
424 path
425 }
426
427 const MODERN: &[&str] = &[
430 r#"{"timestamp":"2026-08-06T10:11:24Z","type":"session_meta","payload":{"session_id":"019fd80e-eb27-70e3-ad2e-ed939930901a","id":"019fd80e-eb27-70e3-ad2e-ed939930901a","timestamp":"2026-08-06T10:11:24Z","cwd":"/repo","originator":"codex_cli_rs","cli_version":"0.145.0","source":"cli","git":{"commit_hash":"abc123","branch":"main","repository_url":"git@example.com:o/r.git"}}}"#,
431 r#"{"timestamp":"2026-08-06T10:11:25Z","type":"turn_context","payload":{"model":"gpt-5.6-sol","cwd":"/repo"}}"#,
432 r#"{"timestamp":"2026-08-06T10:11:30Z","type":"event_msg","payload":{"type":"agent_message","message":"hello"}}"#,
433 r#"{"timestamp":"2026-08-06T10:11:31Z","type":"response_item","payload":{"type":"message","role":"assistant"}}"#,
434 ];
435
436 const LEGACY: &[&str] = &[
438 r#"{"id":"7b332612-1b8e-424b-bbb4-a239a64377fb","timestamp":"2025-09-01T19:51:34Z","instructions":null,"git":{"commit_hash":"old123"}}"#,
439 r#"{"record_type":"state"}"#,
440 r#"{"id":"item_0","type":"message","role":"user","content":[]}"#,
441 ];
442
443 #[test]
444 fn a_missing_sessions_directory_is_empty_not_an_error() {
445 let home = temp_home("empty");
446 assert_eq!(list_in(&home, &SessionQuery::new()).unwrap(), vec![]);
447 }
448
449 #[test]
450 fn lists_sessions_newest_first() {
451 let home = temp_home("order");
452 write_session(
453 &home,
454 (2026, 8, 1),
455 "rollout-2026-08-01T10-00-00-aaaaaaaa-1111-2222-3333-444444444444.jsonl",
456 MODERN,
457 );
458 write_session(
459 &home,
460 (2026, 8, 6),
461 "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
462 MODERN,
463 );
464 write_session(
465 &home,
466 (2025, 9, 1),
467 "rollout-2025-09-01T19-51-34-cccccccc-1111-2222-3333-444444444444.jsonl",
468 LEGACY,
469 );
470
471 let found = list_in(&home, &SessionQuery::new()).unwrap();
472 let dates: Vec<_> = found.iter().map(|s| s.date).collect();
473 assert_eq!(dates, vec![(2026, 8, 6), (2026, 8, 1), (2025, 9, 1)]);
474 assert_eq!(found[0].id, "bbbbbbbb-1111-2222-3333-444444444444");
475 }
476
477 #[test]
478 fn date_filters_narrow_the_listing() {
479 let home = temp_home("dates");
480 write_session(
481 &home,
482 (2026, 8, 1),
483 "rollout-2026-08-01T10-00-00-aaaaaaaa-1111-2222-3333-444444444444.jsonl",
484 MODERN,
485 );
486 write_session(
487 &home,
488 (2026, 8, 6),
489 "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
490 MODERN,
491 );
492
493 let after = list_in(&home, &SessionQuery::new().after(2026, 8, 5)).unwrap();
494 assert_eq!(after.len(), 1);
495 assert_eq!(after[0].date, (2026, 8, 6));
496
497 let before = list_in(&home, &SessionQuery::new().before(2026, 8, 5)).unwrap();
498 assert_eq!(before.len(), 1);
499 assert_eq!(before[0].date, (2026, 8, 1));
500
501 let between = list_in(
502 &home,
503 &SessionQuery::new().after(2026, 8, 1).before(2026, 8, 6),
504 )
505 .unwrap();
506 assert_eq!(between.len(), 2);
507 }
508
509 #[test]
510 fn cwd_filter_matches_the_recorded_directory() {
511 let home = temp_home("cwd");
512 write_session(
513 &home,
514 (2026, 8, 6),
515 "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
516 MODERN,
517 );
518
519 assert_eq!(
520 list_in(&home, &SessionQuery::new().cwd("/repo"))
521 .unwrap()
522 .len(),
523 1
524 );
525 assert_eq!(
526 list_in(&home, &SessionQuery::new().cwd("/elsewhere"))
527 .unwrap()
528 .len(),
529 0
530 );
531 }
532
533 #[test]
534 fn reads_a_modern_session() {
535 let home = temp_home("modern");
536 let path = write_session(
537 &home,
538 (2026, 8, 6),
539 "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
540 MODERN,
541 );
542
543 let log = read(&path).unwrap();
544 let meta = log.meta.unwrap();
545 assert_eq!(meta.cwd, Some(PathBuf::from("/repo")));
546 assert_eq!(meta.cli_version.as_deref(), Some("0.145.0"));
547 assert_eq!(meta.git.unwrap().branch.as_deref(), Some("main"));
548
549 assert_eq!(log.entries.len(), 3);
551 assert_eq!(log.entries[0].entry_type.as_deref(), Some("turn_context"));
552 assert_eq!(log.entries[1].payload_type(), Some("agent_message"));
553 }
554
555 #[test]
558 fn reads_a_legacy_session_without_an_envelope() {
559 let home = temp_home("legacy");
560 let path = write_session(
561 &home,
562 (2025, 9, 1),
563 "rollout-2025-09-01T19-51-34-cccccccc-1111-2222-3333-444444444444.jsonl",
564 LEGACY,
565 );
566
567 let log = read(&path).unwrap();
568 let meta = log.meta.expect("the first line is the metadata");
569 assert_eq!(
570 meta.id.as_deref(),
571 Some("7b332612-1b8e-424b-bbb4-a239a64377fb")
572 );
573 assert_eq!(meta.cli_version, None, "legacy files record no version");
574 assert_eq!(meta.cwd, None, "legacy files record no cwd");
575 assert_eq!(meta.git.unwrap().commit_hash.as_deref(), Some("old123"));
576
577 assert_eq!(log.entries.len(), 2);
578 assert_eq!(log.entries[0].entry_type, None);
580 assert_eq!(log.entries[1].payload_type(), Some("message"));
581 }
582
583 #[test]
586 fn a_truncated_tail_does_not_lose_the_rest() {
587 let home = temp_home("truncated");
588 let mut lines = MODERN.to_vec();
589 lines.push(r#"{"timestamp":"2026-08-06T10:11:32Z","type":"event_ms"#);
590 let path = write_session(
591 &home,
592 (2026, 8, 6),
593 "rollout-2026-08-06T10-11-24-dddddddd-1111-2222-3333-444444444444.jsonl",
594 &lines,
595 );
596
597 let log = read(&path).unwrap();
598 assert!(log.meta.is_some());
599 assert_eq!(log.entries.len(), 3, "the good lines survive");
600 }
601
602 #[test]
605 fn session_id_comes_from_the_uuid_tail() {
606 assert_eq!(
607 session_id_from_path(Path::new(
608 "/x/rollout-2026-08-06T10-11-24-019fd80e-eb27-70e3-ad2e-ed939930901a.jsonl"
609 )),
610 Some("019fd80e-eb27-70e3-ad2e-ed939930901a".to_string())
611 );
612 assert_eq!(session_id_from_path(Path::new("/x/notes.txt")), None);
613 }
614
615 #[test]
616 fn unknown_entry_types_are_kept_rather_than_dropped() {
617 let home = temp_home("unknown");
618 let path = write_session(
619 &home,
620 (2026, 8, 6),
621 "rollout-2026-08-06T10-11-24-eeeeeeee-1111-2222-3333-444444444444.jsonl",
622 &[
623 MODERN[0],
624 r#"{"timestamp":"2026-08-06T10:11:40Z","type":"something_new","payload":{"type":"unheard_of"}}"#,
625 ],
626 );
627
628 let log = read(&path).unwrap();
629 assert_eq!(log.entries.len(), 1);
630 assert_eq!(log.entries[0].entry_type.as_deref(), Some("something_new"));
631 assert_eq!(log.entries[0].payload_type(), Some("unheard_of"));
632 }
633}