use super::records::{CodexJsonlRecord, CodexSessionMetaPayload};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde_json::Value;
pub(crate) fn normalize_line(
raw: &str,
session_ts: Option<DateTime<Utc>>,
) -> Result<Option<CodexJsonlRecord>> {
let value: Value = serde_json::from_str(raw)?;
if value.get("type").is_some()
&& value.get("payload").is_some()
&& value.get("timestamp").is_some()
{
let record: CodexJsonlRecord = serde_json::from_value(value)?;
return Ok(Some(record));
}
Ok(normalize_legacy(value, session_ts))
}
pub(crate) fn legacy_session_meta(value: &Value, cwd: String) -> Option<CodexSessionMetaPayload> {
let id = value.get("id")?.as_str()?.to_string();
let ts_str = value.get("timestamp")?.as_str()?;
let timestamp = DateTime::parse_from_rfc3339(ts_str)
.ok()?
.with_timezone(&Utc);
Some(CodexSessionMetaPayload { id, timestamp, cwd })
}
pub(crate) fn is_legacy_meta(value: &Value) -> bool {
value.get("type").is_none()
&& value.get("record_type").is_none()
&& value.get("payload").is_none()
&& value.get("id").and_then(Value::as_str).is_some()
&& value.get("timestamp").and_then(Value::as_str).is_some()
}
pub(crate) fn extract_cwd_from_env_context(value: &Value) -> Option<String> {
if value.get("type").and_then(Value::as_str) != Some("message") {
return None;
}
let content = value.get("content")?.as_array()?;
for part in content {
let text = part.get("text").and_then(Value::as_str)?;
if let Some(start) = text.find("<cwd>")
&& let Some(end) = text[start + 5..].find("</cwd>")
{
return Some(text[start + 5..start + 5 + end].trim().to_string());
}
}
None
}
fn normalize_legacy(value: Value, session_ts: Option<DateTime<Utc>>) -> Option<CodexJsonlRecord> {
if value.get("record_type").is_some() {
return None;
}
if is_legacy_meta(&value) {
let meta = legacy_session_meta(&value, String::new())?;
let payload = serde_json::json!({
"id": meta.id,
"timestamp": meta.timestamp.to_rfc3339(),
"cwd": meta.cwd,
});
return Some(CodexJsonlRecord {
timestamp: meta.timestamp,
kind: "session_meta".to_string(),
payload,
});
}
if let Some(kind) = value.get("type").and_then(Value::as_str)
&& matches!(
kind,
"message" | "reasoning" | "function_call" | "function_call_output"
)
{
let timestamp = session_ts.unwrap_or_else(Utc::now);
return Some(CodexJsonlRecord {
timestamp,
kind: "response_item".to_string(),
payload: value,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_modern_envelope() {
let raw = r#"{"timestamp":"2025-08-28T08:45:13.563Z","type":"session_meta","payload":{"id":"abc","timestamp":"2025-08-28T08:45:13.563Z","cwd":"/tmp"}}"#;
let rec = normalize_line(raw, None).unwrap().unwrap();
assert_eq!(rec.kind, "session_meta");
}
#[test]
fn normalizes_legacy_meta() {
let raw = r#"{"id":"ce59ef14","timestamp":"2025-08-28T08:45:13.563Z","instructions":"hi","git":{}}"#;
let rec = normalize_line(raw, None).unwrap().unwrap();
assert_eq!(rec.kind, "session_meta");
let meta: CodexSessionMetaPayload = serde_json::from_value(rec.payload).unwrap();
assert_eq!(meta.id, "ce59ef14");
assert_eq!(meta.cwd, "");
}
#[test]
fn skips_legacy_state_marker() {
let raw = r#"{"record_type":"state"}"#;
assert!(normalize_line(raw, None).unwrap().is_none());
}
#[test]
fn normalizes_legacy_response_item() {
let raw = r#"{"type":"message","id":null,"role":"user","content":[{"type":"input_text","text":"hi"}]}"#;
let ts: DateTime<Utc> = "2025-08-28T08:45:13.563Z".parse().unwrap();
let rec = normalize_line(raw, Some(ts)).unwrap().unwrap();
assert_eq!(rec.kind, "response_item");
assert_eq!(rec.payload.get("type").unwrap(), "message");
}
#[test]
fn extracts_cwd_from_env_context() {
let raw = r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/home/riley/spotlessbinco</cwd>\n</environment_context>"}]}"#;
let value: Value = serde_json::from_str(raw).unwrap();
assert_eq!(
extract_cwd_from_env_context(&value).as_deref(),
Some("/home/riley/spotlessbinco")
);
}
}