use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const TOOL_RESULT_PREVIEW_CHARS: usize = 400;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionMessage {
pub role: String,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedSession {
pub messages: Vec<SessionMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_ts: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_ts: Option<DateTime<Utc>>,
}
pub fn parse_jsonl(text: &str) -> ParsedSession {
let mut session = ParsedSession::default();
for (lineno, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
tracing::trace!(line = lineno + 1, error = %e, "skipping bad JSONL line");
continue;
}
};
let kind = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
if kind != "user" && kind != "assistant" {
continue;
}
let Some(msg) = v.get("message") else {
continue;
};
let role = msg
.get("role")
.and_then(|x| x.as_str())
.unwrap_or(kind)
.to_string();
let text = extract_text(msg.get("content").unwrap_or(&Value::Null));
if text.is_empty() {
continue; }
let timestamp = v
.get("timestamp")
.and_then(|x| x.as_str())
.and_then(parse_rfc3339);
let uuid = v.get("uuid").and_then(|x| x.as_str()).map(str::to_owned);
if let Some(t) = timestamp {
session.first_ts = Some(session.first_ts.map(|f| f.min(t)).unwrap_or(t));
session.last_ts = Some(session.last_ts.map(|l| l.max(t)).unwrap_or(t));
}
session.messages.push(SessionMessage {
role,
text,
timestamp,
uuid,
});
}
session
}
fn parse_rfc3339(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|d| d.with_timezone(&Utc))
}
fn extract_text(content: &Value) -> String {
match content {
Value::String(s) => s.clone(),
Value::Array(blocks) => {
let mut parts: Vec<String> = Vec::with_capacity(blocks.len());
for block in blocks {
let typ = block.get("type").and_then(|x| x.as_str()).unwrap_or("");
match typ {
"text" => {
if let Some(t) = block.get("text").and_then(|x| x.as_str()) {
let t = t.trim();
if !t.is_empty() {
parts.push(t.to_string());
}
}
}
"tool_use" => {
let name = block.get("name").and_then(|x| x.as_str()).unwrap_or("?");
parts.push(format!("[tool_use: {name}]"));
}
"tool_result" => {
let raw = block.get("content").and_then(|x| x.as_str()).unwrap_or("");
let preview: String = raw.chars().take(TOOL_RESULT_PREVIEW_CHARS).collect();
let truncated = if raw.chars().count() > TOOL_RESULT_PREVIEW_CHARS {
"…"
} else {
""
};
parts.push(format!(
"[tool_result({chars} chars): {preview}{truncated}]",
chars = raw.chars().count(),
));
}
_ => {}
}
}
parts.join("\n\n")
}
_ => String::new(),
}
}
pub fn render_markdown(session: &ParsedSession) -> String {
if session.messages.is_empty() {
return String::new();
}
let mut out = String::with_capacity(session.messages.len() * 128);
for m in &session.messages {
match m.timestamp {
Some(t) => out.push_str(&format!(
"**{}** — {}\n\n",
m.role,
t.format("%Y-%m-%dT%H:%MZ")
)),
None => out.push_str(&format!("**{}**\n\n", m.role)),
}
out.push_str(m.text.trim());
out.push_str("\n\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn line(json: &str) -> String {
json.replace('\n', "")
}
#[test]
fn parse_keeps_only_user_assistant_turns() {
let input = [
line(r#"{"type":"permission-mode","permissionMode":"default"}"#),
line(r#"{"type":"file-history-snapshot","messageId":"x"}"#),
line(r#"{"type":"ai-title","title":"about something"}"#),
line(r#"{"type":"system","content":"system note"}"#),
line(r#"{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-17T03:14:00Z","uuid":"u1"}"#),
line(r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]},"timestamp":"2026-05-17T03:14:05Z","uuid":"a1"}"#),
]
.join("\n");
let s = parse_jsonl(&input);
assert_eq!(s.messages.len(), 2, "only the 2 real turns are kept");
assert_eq!(s.messages[0].role, "user");
assert_eq!(s.messages[0].text, "hi");
assert_eq!(s.messages[0].uuid.as_deref(), Some("u1"));
assert_eq!(s.messages[1].role, "assistant");
assert_eq!(s.messages[1].text, "hello");
}
#[test]
fn parse_extracts_first_and_last_timestamp() {
let input = [
r#"{"type":"user","message":{"role":"user","content":"a"},"timestamp":"2026-05-17T03:14:00Z"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"b"}]},"timestamp":"2026-05-17T03:14:30Z"}"#,
r#"{"type":"user","message":{"role":"user","content":"c"},"timestamp":"2026-05-17T03:15:00Z"}"#,
]
.join("\n");
let s = parse_jsonl(&input);
assert_eq!(
s.first_ts.map(|t| t.to_rfc3339()),
Some("2026-05-17T03:14:00+00:00".to_string())
);
assert_eq!(
s.last_ts.map(|t| t.to_rfc3339()),
Some("2026-05-17T03:15:00+00:00".to_string())
);
}
#[test]
fn parse_handles_array_content_with_mixed_block_types() {
let input = r#"{"type":"assistant","message":{"role":"assistant","content":[
{"type":"thinking","thinking":"internal monologue should be dropped"},
{"type":"text","text":"Hello there"},
{"type":"tool_use","id":"toolu_1","name":"Read","input":{"path":"/x"}},
{"type":"text","text":"Done."}
]},"timestamp":"2026-05-17T03:14:00Z"}"#
.replace('\n', "");
let s = parse_jsonl(&input);
assert_eq!(s.messages.len(), 1);
let text = &s.messages[0].text;
assert!(text.contains("Hello there"));
assert!(text.contains("[tool_use: Read]"));
assert!(text.contains("Done."));
assert!(
!text.contains("internal monologue"),
"thinking blocks must be dropped"
);
}
#[test]
fn parse_truncates_long_tool_results_with_preview() {
let long_output = "x".repeat(1000);
let line = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{long_output}"}}]}},"timestamp":"2026-05-17T03:14:00Z"}}"#,
);
let s = parse_jsonl(&line);
assert_eq!(s.messages.len(), 1);
let text = &s.messages[0].text;
assert!(text.starts_with("[tool_result(1000 chars):"));
assert!(text.ends_with("…]"));
assert!(
text.len() < TOOL_RESULT_PREVIEW_CHARS + 100,
"rendered tool_result must be capped"
);
}
#[test]
fn parse_drops_turns_with_no_text_content() {
let input = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"...","signature":"x"}]},"timestamp":"2026-05-17T03:14:00Z"}"#;
let s = parse_jsonl(input);
assert!(s.messages.is_empty(), "thinking-only turns must be dropped");
}
#[test]
fn parse_tolerates_bad_lines() {
let input = [
r#"{"type":"user","message":{"role":"user","content":"good"},"timestamp":"2026-05-17T03:14:00Z"}"#,
"not-json-garbage",
r#"{ "incomplete: "#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"also good"}]},"timestamp":"2026-05-17T03:14:05Z"}"#,
]
.join("\n");
let s = parse_jsonl(&input);
assert_eq!(s.messages.len(), 2);
}
#[test]
fn parse_empty_input_returns_default() {
let s = parse_jsonl("");
assert!(s.messages.is_empty());
assert!(s.first_ts.is_none());
assert!(s.last_ts.is_none());
}
#[test]
fn render_markdown_uses_role_and_timestamp_headers() {
let s = ParsedSession {
messages: vec![
SessionMessage {
role: "user".into(),
text: "hi there".into(),
timestamp: parse_rfc3339("2026-05-17T03:14:00Z"),
uuid: None,
},
SessionMessage {
role: "assistant".into(),
text: "hello back".into(),
timestamp: parse_rfc3339("2026-05-17T03:14:05Z"),
uuid: None,
},
],
first_ts: parse_rfc3339("2026-05-17T03:14:00Z"),
last_ts: parse_rfc3339("2026-05-17T03:14:05Z"),
};
let md = render_markdown(&s);
assert!(md.contains("**user**"));
assert!(md.contains("**assistant**"));
assert!(md.contains("2026-05-17T03:14Z"));
assert!(md.contains("hi there"));
assert!(md.contains("hello back"));
}
#[test]
fn render_markdown_handles_missing_timestamp() {
let s = ParsedSession {
messages: vec![SessionMessage {
role: "user".into(),
text: "hi".into(),
timestamp: None,
uuid: None,
}],
first_ts: None,
last_ts: None,
};
let md = render_markdown(&s);
assert!(md.contains("**user**"));
assert!(!md.contains("—"));
assert!(md.contains("hi"));
}
#[test]
fn render_markdown_empty_session_is_empty_string() {
assert_eq!(render_markdown(&ParsedSession::default()), "");
}
#[test]
fn chunk_count_estimate_drops_dramatically() {
let mut raw = String::new();
for i in 0..200 {
raw.push_str(&format!(
r#"{{"type":"user","message":{{"role":"user","content":"turn {i}"}},"timestamp":"2026-05-17T03:14:00Z"}}"#,
));
raw.push('\n');
}
let s = parse_jsonl(&raw);
let md = render_markdown(&s);
assert_eq!(s.messages.len(), 200);
assert!(
md.len() < raw.len() / 2,
"rendered markdown ({}) should be much smaller than raw JSONL ({})",
md.len(),
raw.len()
);
}
}