1use crate::trace::{Agent, Event, Session, SessionContext, Stats};
16use serde::{de::DeserializeOwned, Deserialize, Serialize};
17use std::io::{self, BufRead, Write};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type")]
22#[non_exhaustive]
23pub enum HailLine {
24 #[serde(rename = "header")]
26 Header {
27 version: String,
28 session_id: String,
29 agent: Agent,
30 context: SessionContext,
31 },
32 #[serde(rename = "event")]
34 Event(Event),
35 #[serde(rename = "stats")]
37 Stats(Stats),
38}
39
40#[derive(Debug, thiserror::Error)]
42#[non_exhaustive]
43pub enum JsonlError {
44 #[error("IO error: {0}")]
45 Io(#[from] io::Error),
46 #[error("JSON error at line {line}: {source}")]
47 Json {
48 line: usize,
49 source: serde_json::Error,
50 },
51 #[error("Missing header line")]
52 MissingHeader,
53 #[error("Unexpected line type at line {0}: expected header")]
54 UnexpectedLineType(usize),
55}
56
57fn json_to_writer_line<W: Write, T: Serialize>(
58 writer: &mut W,
59 value: &T,
60 line: usize,
61) -> Result<(), JsonlError> {
62 serde_json::to_writer(writer, value).map_err(|source| JsonlError::Json { line, source })
63}
64
65fn json_from_str_line<T: DeserializeOwned>(input: &str, line: usize) -> Result<T, JsonlError> {
66 serde_json::from_str(input).map_err(|source| JsonlError::Json { line, source })
67}
68
69pub fn write_jsonl<W: Write>(session: &Session, mut writer: W) -> Result<(), JsonlError> {
71 let header = HailLine::Header {
73 version: session.version.clone(),
74 session_id: session.session_id.clone(),
75 agent: session.agent.clone(),
76 context: session.context.clone(),
77 };
78 json_to_writer_line(&mut writer, &header, 1)?;
79 writer.write_all(b"\n")?;
80
81 for (i, event) in session.events.iter().enumerate() {
83 let line = HailLine::Event(event.clone());
84 json_to_writer_line(&mut writer, &line, i + 2)?;
85 writer.write_all(b"\n")?;
86 }
87
88 let stats_line = HailLine::Stats(session.stats.clone());
90 json_to_writer_line(&mut writer, &stats_line, session.events.len() + 2)?;
91 writer.write_all(b"\n")?;
92
93 Ok(())
94}
95
96pub fn to_jsonl_string(session: &Session) -> Result<String, JsonlError> {
98 let mut buf = Vec::new();
99 write_jsonl(session, &mut buf)?;
100 Ok(String::from_utf8(buf).unwrap())
102}
103
104pub fn read_jsonl<R: BufRead>(reader: R) -> Result<Session, JsonlError> {
106 let mut lines = reader.lines();
107
108 let header_str = lines.next().ok_or(JsonlError::MissingHeader)??;
110 let header: HailLine = json_from_str_line(&header_str, 1)?;
111
112 let (version, session_id, agent, context) = match header {
113 HailLine::Header {
114 version,
115 session_id,
116 agent,
117 context,
118 } => (version, session_id, agent, context),
119 _ => return Err(JsonlError::UnexpectedLineType(1)),
120 };
121
122 let mut events = Vec::new();
123 let mut stats = None;
124 let mut line_num = 1usize;
125
126 for line_result in lines {
127 line_num += 1;
128 let line_str = line_result?;
129 if line_str.is_empty() {
130 continue;
131 }
132
133 let hail_line: HailLine = json_from_str_line(&line_str, line_num)?;
134
135 match hail_line {
136 HailLine::Event(event) => events.push(event),
137 HailLine::Stats(s) => stats = Some(s),
138 HailLine::Header { .. } => {
139 }
141 }
142 }
143
144 let has_stats = stats.is_some();
145 let mut session = Session {
146 version,
147 session_id,
148 agent,
149 context,
150 events,
151 stats: stats.unwrap_or_default(),
152 };
153
154 if !has_stats {
156 session.recompute_stats();
157 }
158
159 Ok(session)
160}
161
162pub fn from_jsonl_str(s: &str) -> Result<Session, JsonlError> {
164 read_jsonl(io::BufReader::new(s.as_bytes()))
165}
166
167pub fn read_header<R: BufRead>(
170 reader: R,
171) -> Result<(String, String, Agent, SessionContext), JsonlError> {
172 let mut lines = reader.lines();
173 let header_str = lines.next().ok_or(JsonlError::MissingHeader)??;
174 let header: HailLine = json_from_str_line(&header_str, 1)?;
175
176 match header {
177 HailLine::Header {
178 version,
179 session_id,
180 agent,
181 context,
182 } => Ok((version, session_id, agent, context)),
183 _ => Err(JsonlError::UnexpectedLineType(1)),
184 }
185}
186
187pub fn read_header_and_stats(
190 data: &str,
191) -> Result<(String, String, Agent, SessionContext, Option<Stats>), JsonlError> {
192 let mut lines = data.lines();
193
194 let header_str = lines.next().ok_or(JsonlError::MissingHeader)?;
196 let header: HailLine = json_from_str_line(header_str, 1)?;
197
198 let (version, session_id, agent, context) = match header {
199 HailLine::Header {
200 version,
201 session_id,
202 agent,
203 context,
204 } => (version, session_id, agent, context),
205 _ => return Err(JsonlError::UnexpectedLineType(1)),
206 };
207
208 let mut last_line = None;
210 let mut line_num = 1usize;
211 for line in lines {
212 line_num += 1;
213 if !line.is_empty() {
214 last_line = Some((line_num, line));
215 }
216 }
217
218 let stats = if let Some((_ln, last)) = last_line {
219 match serde_json::from_str::<HailLine>(last) {
220 Ok(HailLine::Stats(s)) => Some(s),
221 Ok(_) => None,
222 Err(_) => None, }
224 } else {
225 None
226 };
227
228 Ok((version, session_id, agent, context, stats))
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::trace::{Content, EventType};
235 use chrono::Utc;
236 use std::collections::HashMap;
237
238 fn make_test_session() -> Session {
239 let mut session = Session::new(
240 "test-jsonl-123".to_string(),
241 Agent {
242 provider: "anthropic".to_string(),
243 model: "claude-opus-4-6".to_string(),
244 tool: "claude-code".to_string(),
245 tool_version: Some("1.2.3".to_string()),
246 },
247 );
248 session.context.title = Some("Test JSONL session".to_string());
249
250 let ts = Utc::now();
251 session.events.push(Event {
252 event_id: "e1".to_string(),
253 timestamp: ts,
254 event_type: EventType::UserMessage,
255 task_id: None,
256 content: Content::text("Hello, can you help me?"),
257 duration_ms: None,
258 attributes: HashMap::new(),
259 });
260 session.events.push(Event {
261 event_id: "e2".to_string(),
262 timestamp: ts,
263 event_type: EventType::AgentMessage,
264 task_id: None,
265 content: Content::text("Sure! What do you need?"),
266 duration_ms: None,
267 attributes: HashMap::new(),
268 });
269 session.events.push(Event {
270 event_id: "e3".to_string(),
271 timestamp: ts,
272 event_type: EventType::FileRead {
273 path: "/tmp/test.rs".to_string(),
274 },
275 task_id: Some("t1".to_string()),
276 content: Content::code("fn main() {}", Some("rust".to_string())),
277 duration_ms: Some(50),
278 attributes: HashMap::new(),
279 });
280
281 session.recompute_stats();
282 session
283 }
284
285 #[test]
286 fn test_jsonl_roundtrip() {
287 let session = make_test_session();
288 let jsonl = to_jsonl_string(&session).unwrap();
289
290 let lines: Vec<&str> = jsonl.trim().lines().collect();
292 assert_eq!(lines.len(), 5);
293
294 assert!(lines[0].contains("\"type\":\"header\""));
296 assert!(lines[0].contains("hail-1.0.0"));
297
298 assert!(lines[1].contains("\"type\":\"event\""));
300 assert!(lines[2].contains("\"type\":\"event\""));
301 assert!(lines[3].contains("\"type\":\"event\""));
302
303 assert!(lines[4].contains("\"type\":\"stats\""));
305
306 let parsed = from_jsonl_str(&jsonl).unwrap();
308 assert_eq!(parsed.version, "hail-1.0.0");
309 assert_eq!(parsed.session_id, "test-jsonl-123");
310 assert_eq!(parsed.events.len(), 3);
311 assert_eq!(parsed.stats.message_count, 2);
312 assert_eq!(parsed.stats.tool_call_count, 1);
313 assert_eq!(parsed.stats.event_count, 3);
314 assert_eq!(parsed.agent.tool, "claude-code");
315 assert_eq!(parsed.context.title, Some("Test JSONL session".to_string()));
316 }
317
318 #[test]
319 fn test_jsonl_empty_session() {
320 let session = Session::new(
321 "empty-session".to_string(),
322 Agent {
323 provider: "openai".to_string(),
324 model: "gpt-4o".to_string(),
325 tool: "codex".to_string(),
326 tool_version: None,
327 },
328 );
329
330 let jsonl = to_jsonl_string(&session).unwrap();
331 let lines: Vec<&str> = jsonl.trim().lines().collect();
332 assert_eq!(lines.len(), 2); let parsed = from_jsonl_str(&jsonl).unwrap();
335 assert_eq!(parsed.events.len(), 0);
336 assert_eq!(parsed.stats.event_count, 0);
337 }
338
339 #[test]
340 fn test_read_header_only() {
341 let session = make_test_session();
342 let jsonl = to_jsonl_string(&session).unwrap();
343
344 let (version, session_id, agent, context) =
345 read_header(io::BufReader::new(jsonl.as_bytes())).unwrap();
346
347 assert_eq!(version, "hail-1.0.0");
348 assert_eq!(session_id, "test-jsonl-123");
349 assert_eq!(agent.tool, "claude-code");
350 assert_eq!(context.title, Some("Test JSONL session".to_string()));
351 }
352
353 #[test]
354 fn test_read_header_and_stats() {
355 let session = make_test_session();
356 let jsonl = to_jsonl_string(&session).unwrap();
357
358 let (version, session_id, _agent, _context, stats) = read_header_and_stats(&jsonl).unwrap();
359
360 assert_eq!(version, "hail-1.0.0");
361 assert_eq!(session_id, "test-jsonl-123");
362 let stats = stats.unwrap();
363 assert_eq!(stats.event_count, 3);
364 assert_eq!(stats.message_count, 2);
365 }
366
367 #[test]
368 fn test_missing_stats_recomputes() {
369 let session = make_test_session();
371 let jsonl = to_jsonl_string(&session).unwrap();
372
373 let without_stats: String = jsonl.lines().take(4).collect::<Vec<_>>().join("\n") + "\n";
375
376 let parsed = from_jsonl_str(&without_stats).unwrap();
377 assert_eq!(parsed.stats.event_count, 3);
378 assert_eq!(parsed.stats.message_count, 2);
379 }
380
381 #[test]
382 fn test_hailline_serde_tag() {
383 let header = HailLine::Header {
384 version: "hail-1.0.0".to_string(),
385 session_id: "s1".to_string(),
386 agent: Agent {
387 provider: "test".to_string(),
388 model: "test".to_string(),
389 tool: "test".to_string(),
390 tool_version: None,
391 },
392 context: SessionContext::default(),
393 };
394
395 let json = serde_json::to_string(&header).unwrap();
396 assert!(json.contains("\"type\":\"header\""));
397
398 let parsed: HailLine = serde_json::from_str(&json).unwrap();
399 match parsed {
400 HailLine::Header { version, .. } => assert_eq!(version, "hail-1.0.0"),
401 _ => panic!("Expected Header"),
402 }
403 }
404
405 #[test]
406 fn test_jsonl_preserves_task_ids() {
407 let session = make_test_session();
408 let jsonl = to_jsonl_string(&session).unwrap();
409 let parsed = from_jsonl_str(&jsonl).unwrap();
410
411 assert_eq!(parsed.events[2].task_id, Some("t1".to_string()));
413 assert_eq!(parsed.events[0].task_id, None);
415 }
416}