1use serde::Deserialize;
7
8#[derive(Debug, Clone)]
11pub enum SessionEntry {
12 User(UserEntry),
13 Assistant(AssistantEntry),
14 Summary(SummaryEntry),
15 Other,
17}
18
19#[derive(Debug, Clone, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct UserEntry {
22 pub uuid: String,
23 pub timestamp: String,
24 pub session_id: Option<String>,
25 pub message: Option<UserMessage>,
26 #[serde(default)]
27 pub cwd: Option<String>,
28}
29
30#[derive(Debug, Clone, Deserialize)]
31pub struct UserMessage {
32 pub content: serde_json::Value, }
34
35#[derive(Debug, Clone, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct AssistantEntry {
38 pub uuid: String,
39 pub timestamp: String,
40 pub session_id: Option<String>,
41 pub message: Option<AssistantMessage>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct AssistantMessage {
46 pub content: Vec<ContentBlock>,
47 #[serde(default)]
48 pub model: Option<String>,
49 #[serde(default)]
50 pub stop_reason: Option<String>,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum ContentBlock {
56 Text {
57 text: String,
58 },
59 ToolUse {
60 name: String,
61 input: serde_json::Value,
62 },
63 ToolResult {
64 #[serde(default)]
65 content: serde_json::Value,
66 },
67 Thinking {
68 #[serde(default)]
69 thinking: Option<String>,
70 },
71 #[serde(other)]
72 Unknown,
73}
74
75#[derive(Debug, Clone, Deserialize)]
76pub struct SummaryEntry {
77 pub summary: String,
78 #[serde(default)]
79 pub timestamp: Option<String>,
80}
81
82#[derive(Debug, Clone)]
84pub struct ParsedSession {
85 pub session_id: String,
86 pub file_path: String,
87 pub entries: Vec<SessionEntry>,
88 pub first_timestamp: Option<String>,
89 pub last_timestamp: Option<String>,
90}
91
92impl ParsedSession {
93 pub fn first_user_text(&self) -> Option<String> {
95 for entry in &self.entries {
96 if let SessionEntry::User(u) = entry {
97 let text = extract_user_text(u)?;
98 if !text.trim().is_empty() {
99 return Some(text);
100 }
101 }
102 }
103 None
104 }
105
106 pub fn summary(&self) -> Option<&str> {
108 for entry in &self.entries {
109 if let SessionEntry::Summary(s) = entry {
110 return Some(&s.summary);
111 }
112 }
113 None
114 }
115
116 pub fn user_message_count(&self) -> usize {
118 self.entries
119 .iter()
120 .filter(|e| matches!(e, SessionEntry::User(_)))
121 .count()
122 }
123
124 pub fn assistant_message_count(&self) -> usize {
126 self.entries
127 .iter()
128 .filter(|e| matches!(e, SessionEntry::Assistant(_)))
129 .count()
130 }
131}
132
133pub fn parse_session(path: &std::path::Path) -> anyhow::Result<ParsedSession> {
135 let file_name = path
136 .file_stem()
137 .and_then(|s| s.to_str())
138 .unwrap_or("unknown")
139 .to_string();
140
141 let file = std::fs::File::open(path)?;
142 let reader = std::io::BufReader::new(file);
143
144 let mut entries = Vec::new();
145 let mut first_ts: Option<String> = None;
146 let mut last_ts: Option<String> = None;
147
148 use std::io::BufRead;
149 for line in reader.lines() {
150 let line = line?;
151 if line.trim().is_empty() {
152 continue;
153 }
154 let raw: serde_json::Value = match serde_json::from_str(&line) {
155 Ok(v) => v,
156 Err(_) => continue, };
158
159 let entry_type = raw.get("type").and_then(|v| v.as_str()).unwrap_or("");
160 let timestamp = raw
161 .get("timestamp")
162 .and_then(|v| v.as_str())
163 .map(String::from);
164
165 if let Some(ref ts) = timestamp {
166 if first_ts.is_none() {
167 first_ts = Some(ts.clone());
168 }
169 last_ts = Some(ts.clone());
170 }
171
172 let entry = match entry_type {
173 "user" => match serde_json::from_value::<UserEntry>(raw) {
174 Ok(u) => SessionEntry::User(u),
175 Err(_) => SessionEntry::Other,
176 },
177 "assistant" => match serde_json::from_value::<AssistantEntry>(raw) {
178 Ok(a) => SessionEntry::Assistant(a),
179 Err(_) => SessionEntry::Other,
180 },
181 "summary" => match serde_json::from_value::<SummaryEntry>(raw) {
182 Ok(s) => SessionEntry::Summary(s),
183 Err(_) => SessionEntry::Other,
184 },
185 _ => SessionEntry::Other,
186 };
187
188 if !matches!(entry, SessionEntry::Other) {
190 entries.push(entry);
191 }
192 }
193
194 Ok(ParsedSession {
195 session_id: file_name,
196 file_path: path.to_string_lossy().into_owned(),
197 entries,
198 first_timestamp: first_ts,
199 last_timestamp: last_ts,
200 })
201}
202
203pub fn extract_user_text(entry: &UserEntry) -> Option<String> {
206 let msg = entry.message.as_ref()?;
207 match &msg.content {
208 serde_json::Value::String(s) => Some(s.clone()),
209 serde_json::Value::Array(arr) => {
210 let texts: Vec<&str> = arr
211 .iter()
212 .filter_map(|block| {
213 if block.get("type")?.as_str()? == "text" {
214 block.get("text")?.as_str()
215 } else {
216 None
217 }
218 })
219 .collect();
220 if texts.is_empty() {
221 None
222 } else {
223 Some(texts.join("\n"))
224 }
225 }
226 _ => None,
227 }
228}
229
230pub fn extract_assistant_texts(entry: &AssistantEntry) -> Vec<String> {
232 let Some(msg) = &entry.message else {
233 return vec![];
234 };
235 msg.content
236 .iter()
237 .filter_map(|block| match block {
238 ContentBlock::Text { text } => Some(text.clone()),
239 _ => None,
240 })
241 .collect()
242}
243
244pub fn extract_tool_uses(entry: &AssistantEntry) -> Vec<(String, serde_json::Value)> {
246 let Some(msg) = &entry.message else {
247 return vec![];
248 };
249 msg.content
250 .iter()
251 .filter_map(|block| match block {
252 ContentBlock::ToolUse { name, input } => Some((name.clone(), input.clone())),
253 _ => None,
254 })
255 .collect()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn parse_user_string_content() {
264 let json = r#"{"type":"user","uuid":"abc","timestamp":"2026-01-01T00:00:00Z","message":{"content":"hello world"}}"#;
265 let raw: serde_json::Value = serde_json::from_str(json).unwrap();
266 let entry: UserEntry = serde_json::from_value(raw).unwrap();
267 let text = extract_user_text(&entry).unwrap();
268 assert_eq!(text, "hello world");
269 }
270
271 #[test]
272 fn parse_user_array_content() {
273 let json = r#"{"type":"user","uuid":"abc","timestamp":"2026-01-01T00:00:00Z","message":{"content":[{"type":"text","text":"fix the bug"}]}}"#;
274 let raw: serde_json::Value = serde_json::from_str(json).unwrap();
275 let entry: UserEntry = serde_json::from_value(raw).unwrap();
276 let text = extract_user_text(&entry).unwrap();
277 assert_eq!(text, "fix the bug");
278 }
279
280 #[test]
281 fn parse_assistant_with_tool_use() {
282 let json = r#"{"type":"assistant","uuid":"def","timestamp":"2026-01-01T00:00:01Z","message":{"content":[{"type":"text","text":"Let me check"},{"type":"tool_use","name":"Read","input":{"file_path":"/tmp/x"}}]}}"#;
283 let raw: serde_json::Value = serde_json::from_str(json).unwrap();
284 let entry: AssistantEntry = serde_json::from_value(raw).unwrap();
285 let texts = extract_assistant_texts(&entry);
286 assert_eq!(texts, vec!["Let me check"]);
287 let tools = extract_tool_uses(&entry);
288 assert_eq!(tools.len(), 1);
289 assert_eq!(tools[0].0, "Read");
290 }
291
292 #[test]
295 fn parse_session_with_valid_jsonl() {
296 let dir = tempfile::tempdir().unwrap();
297 let path = dir.path().join("abc123.jsonl");
298 let lines = [
299 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"content":"hello"}}"#,
300 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"content":[{"type":"text","text":"hi there"}]}}"#,
301 r#"{"type":"summary","summary":"This session was about greeting.","timestamp":"2026-01-01T00:00:02Z"}"#,
302 ];
303 std::fs::write(&path, lines.join("\n")).unwrap();
304
305 let session = parse_session(&path).unwrap();
306 assert_eq!(session.session_id, "abc123");
307 assert_eq!(session.entries.len(), 3);
308 assert_eq!(
309 session.first_timestamp.as_deref(),
310 Some("2026-01-01T00:00:00Z")
311 );
312 assert_eq!(
313 session.last_timestamp.as_deref(),
314 Some("2026-01-01T00:00:02Z")
315 );
316 }
317
318 #[test]
319 fn parse_session_skips_empty_and_malformed_lines() {
320 let dir = tempfile::tempdir().unwrap();
321 let path = dir.path().join("sess.jsonl");
322 let lines = [
323 "",
324 "not-json-at-all",
325 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"content":"valid"}}"#,
326 " ",
327 r#"{"type":"unknown_type","data":"ignored"}"#,
328 ];
329 std::fs::write(&path, lines.join("\n")).unwrap();
330
331 let session = parse_session(&path).unwrap();
332 assert_eq!(session.entries.len(), 1);
334 }
335
336 #[test]
337 fn parse_session_empty_file() {
338 let dir = tempfile::tempdir().unwrap();
339 let path = dir.path().join("empty.jsonl");
340 std::fs::write(&path, "").unwrap();
341
342 let session = parse_session(&path).unwrap();
343 assert!(session.entries.is_empty());
344 assert!(session.first_timestamp.is_none());
345 assert!(session.last_timestamp.is_none());
346 }
347
348 #[test]
349 fn parse_session_nonexistent_file() {
350 let result = parse_session(std::path::Path::new("/nonexistent/path.jsonl"));
351 assert!(result.is_err());
352 }
353
354 #[test]
355 fn parse_session_session_id_from_filename() {
356 let dir = tempfile::tempdir().unwrap();
357 let path = dir.path().join("my-session-id.jsonl");
358 std::fs::write(&path, "").unwrap();
359
360 let session = parse_session(&path).unwrap();
361 assert_eq!(session.session_id, "my-session-id");
362 }
363
364 #[test]
367 fn first_user_text_returns_none_when_no_users() {
368 let session = ParsedSession {
369 session_id: "s1".into(),
370 file_path: "/tmp/s1.jsonl".into(),
371 entries: vec![SessionEntry::Assistant(AssistantEntry {
372 uuid: "a1".into(),
373 timestamp: "2026-01-01T00:00:00Z".into(),
374 session_id: None,
375 message: Some(AssistantMessage {
376 content: vec![ContentBlock::Text {
377 text: "hello".into(),
378 }],
379 model: None,
380 stop_reason: None,
381 }),
382 })],
383 first_timestamp: None,
384 last_timestamp: None,
385 };
386 assert!(session.first_user_text().is_none());
387 }
388
389 #[test]
390 fn first_user_text_skips_empty_messages() {
391 let session = ParsedSession {
392 session_id: "s1".into(),
393 file_path: "/tmp/s1.jsonl".into(),
394 entries: vec![
395 SessionEntry::User(UserEntry {
396 uuid: "u1".into(),
397 timestamp: "2026-01-01T00:00:00Z".into(),
398 session_id: None,
399 message: Some(UserMessage {
400 content: serde_json::json!(" "),
401 }),
402 cwd: None,
403 }),
404 SessionEntry::User(UserEntry {
405 uuid: "u2".into(),
406 timestamp: "2026-01-01T00:00:01Z".into(),
407 session_id: None,
408 message: Some(UserMessage {
409 content: serde_json::json!("actual text"),
410 }),
411 cwd: None,
412 }),
413 ],
414 first_timestamp: None,
415 last_timestamp: None,
416 };
417 assert_eq!(session.first_user_text().unwrap(), "actual text");
418 }
419
420 #[test]
421 fn first_user_text_with_xml_tagged_content() {
422 let session = ParsedSession {
424 session_id: "s1".into(),
425 file_path: "/tmp/s1.jsonl".into(),
426 entries: vec![SessionEntry::User(UserEntry {
427 uuid: "u1".into(),
428 timestamp: "2026-01-01T00:00:00Z".into(),
429 session_id: None,
430 message: Some(UserMessage {
431 content: serde_json::json!("<command-name>init</command-name> Setup project"),
432 }),
433 cwd: None,
434 })],
435 first_timestamp: None,
436 last_timestamp: None,
437 };
438 let text = session.first_user_text().unwrap();
439 assert!(text.contains("<command-name>"));
440 assert!(text.contains("Setup project"));
441 }
442
443 #[test]
444 fn first_user_text_no_message() {
445 let session = ParsedSession {
446 session_id: "s1".into(),
447 file_path: "/tmp/s1.jsonl".into(),
448 entries: vec![SessionEntry::User(UserEntry {
449 uuid: "u1".into(),
450 timestamp: "2026-01-01T00:00:00Z".into(),
451 session_id: None,
452 message: None,
453 cwd: None,
454 })],
455 first_timestamp: None,
456 last_timestamp: None,
457 };
458 assert!(session.first_user_text().is_none());
459 }
460
461 #[test]
464 fn summary_returns_none_when_no_summary_entry() {
465 let session = ParsedSession {
466 session_id: "s1".into(),
467 file_path: "/tmp/s1.jsonl".into(),
468 entries: vec![SessionEntry::User(UserEntry {
469 uuid: "u1".into(),
470 timestamp: "2026-01-01T00:00:00Z".into(),
471 session_id: None,
472 message: None,
473 cwd: None,
474 })],
475 first_timestamp: None,
476 last_timestamp: None,
477 };
478 assert!(session.summary().is_none());
479 }
480
481 #[test]
482 fn summary_returns_first_summary_text() {
483 let session = ParsedSession {
484 session_id: "s1".into(),
485 file_path: "/tmp/s1.jsonl".into(),
486 entries: vec![
487 SessionEntry::Summary(SummaryEntry {
488 summary: "Worked on tests".into(),
489 timestamp: Some("2026-01-01T00:00:00Z".into()),
490 }),
491 SessionEntry::Summary(SummaryEntry {
492 summary: "Second summary ignored".into(),
493 timestamp: None,
494 }),
495 ],
496 first_timestamp: None,
497 last_timestamp: None,
498 };
499 assert_eq!(session.summary().unwrap(), "Worked on tests");
500 }
501
502 #[test]
505 fn message_counts() {
506 let session = ParsedSession {
507 session_id: "s1".into(),
508 file_path: "/tmp/s1.jsonl".into(),
509 entries: vec![
510 SessionEntry::User(UserEntry {
511 uuid: "u1".into(),
512 timestamp: "t".into(),
513 session_id: None,
514 message: None,
515 cwd: None,
516 }),
517 SessionEntry::User(UserEntry {
518 uuid: "u2".into(),
519 timestamp: "t".into(),
520 session_id: None,
521 message: None,
522 cwd: None,
523 }),
524 SessionEntry::Assistant(AssistantEntry {
525 uuid: "a1".into(),
526 timestamp: "t".into(),
527 session_id: None,
528 message: None,
529 }),
530 SessionEntry::Summary(SummaryEntry {
531 summary: "s".into(),
532 timestamp: None,
533 }),
534 ],
535 first_timestamp: None,
536 last_timestamp: None,
537 };
538 assert_eq!(session.user_message_count(), 2);
539 assert_eq!(session.assistant_message_count(), 1);
540 }
541
542 #[test]
543 fn message_counts_empty_session() {
544 let session = ParsedSession {
545 session_id: "s1".into(),
546 file_path: "/tmp/s1.jsonl".into(),
547 entries: vec![],
548 first_timestamp: None,
549 last_timestamp: None,
550 };
551 assert_eq!(session.user_message_count(), 0);
552 assert_eq!(session.assistant_message_count(), 0);
553 }
554
555 #[test]
558 fn extract_user_text_null_content() {
559 let entry = UserEntry {
560 uuid: "u1".into(),
561 timestamp: "t".into(),
562 session_id: None,
563 message: Some(UserMessage {
564 content: serde_json::Value::Null,
565 }),
566 cwd: None,
567 };
568 assert!(extract_user_text(&entry).is_none());
569 }
570
571 #[test]
572 fn extract_user_text_empty_array() {
573 let entry = UserEntry {
574 uuid: "u1".into(),
575 timestamp: "t".into(),
576 session_id: None,
577 message: Some(UserMessage {
578 content: serde_json::json!([]),
579 }),
580 cwd: None,
581 };
582 assert!(extract_user_text(&entry).is_none());
583 }
584
585 #[test]
586 fn extract_user_text_array_no_text_blocks() {
587 let entry = UserEntry {
588 uuid: "u1".into(),
589 timestamp: "t".into(),
590 session_id: None,
591 message: Some(UserMessage {
592 content: serde_json::json!([{"type": "image", "url": "http://example.com/img.png"}]),
593 }),
594 cwd: None,
595 };
596 assert!(extract_user_text(&entry).is_none());
597 }
598
599 #[test]
600 fn extract_user_text_multiple_text_blocks_joined() {
601 let entry = UserEntry {
602 uuid: "u1".into(),
603 timestamp: "t".into(),
604 session_id: None,
605 message: Some(UserMessage {
606 content: serde_json::json!([
607 {"type": "text", "text": "first"},
608 {"type": "text", "text": "second"}
609 ]),
610 }),
611 cwd: None,
612 };
613 assert_eq!(extract_user_text(&entry).unwrap(), "first\nsecond");
614 }
615
616 #[test]
619 fn extract_assistant_texts_no_message() {
620 let entry = AssistantEntry {
621 uuid: "a1".into(),
622 timestamp: "t".into(),
623 session_id: None,
624 message: None,
625 };
626 assert!(extract_assistant_texts(&entry).is_empty());
627 }
628
629 #[test]
630 fn extract_assistant_texts_filters_out_thinking_and_tool_result() {
631 let entry = AssistantEntry {
632 uuid: "a1".into(),
633 timestamp: "t".into(),
634 session_id: None,
635 message: Some(AssistantMessage {
636 content: vec![
637 ContentBlock::Thinking {
638 thinking: Some("internal thought".into()),
639 },
640 ContentBlock::Text {
641 text: "visible text".into(),
642 },
643 ContentBlock::ToolResult {
644 content: serde_json::json!("result data"),
645 },
646 ContentBlock::ToolUse {
647 name: "Read".into(),
648 input: serde_json::json!({}),
649 },
650 ContentBlock::Text {
651 text: "more text".into(),
652 },
653 ],
654 model: None,
655 stop_reason: None,
656 }),
657 };
658 let texts = extract_assistant_texts(&entry);
659 assert_eq!(texts, vec!["visible text", "more text"]);
660 }
661
662 #[test]
663 fn extract_assistant_texts_empty_content() {
664 let entry = AssistantEntry {
665 uuid: "a1".into(),
666 timestamp: "t".into(),
667 session_id: None,
668 message: Some(AssistantMessage {
669 content: vec![],
670 model: None,
671 stop_reason: None,
672 }),
673 };
674 assert!(extract_assistant_texts(&entry).is_empty());
675 }
676
677 #[test]
680 fn extract_tool_uses_no_message() {
681 let entry = AssistantEntry {
682 uuid: "a1".into(),
683 timestamp: "t".into(),
684 session_id: None,
685 message: None,
686 };
687 assert!(extract_tool_uses(&entry).is_empty());
688 }
689
690 #[test]
691 fn extract_tool_uses_only_returns_tool_use_blocks() {
692 let entry = AssistantEntry {
693 uuid: "a1".into(),
694 timestamp: "t".into(),
695 session_id: None,
696 message: Some(AssistantMessage {
697 content: vec![
698 ContentBlock::Text {
699 text: "Let me help".into(),
700 },
701 ContentBlock::ToolUse {
702 name: "Write".into(),
703 input: serde_json::json!({"file_path": "/tmp/a"}),
704 },
705 ContentBlock::Thinking { thinking: None },
706 ContentBlock::ToolUse {
707 name: "Bash".into(),
708 input: serde_json::json!({"command": "ls"}),
709 },
710 ContentBlock::ToolResult {
711 content: serde_json::json!(null),
712 },
713 ],
714 model: None,
715 stop_reason: None,
716 }),
717 };
718 let tools = extract_tool_uses(&entry);
719 assert_eq!(tools.len(), 2);
720 assert_eq!(tools[0].0, "Write");
721 assert_eq!(tools[1].0, "Bash");
722 }
723
724 #[test]
725 fn extract_tool_uses_preserves_input() {
726 let entry = AssistantEntry {
727 uuid: "a1".into(),
728 timestamp: "t".into(),
729 session_id: None,
730 message: Some(AssistantMessage {
731 content: vec![ContentBlock::ToolUse {
732 name: "Edit".into(),
733 input: serde_json::json!({"file_path": "/src/main.rs", "old_string": "foo", "new_string": "bar"}),
734 }],
735 model: None,
736 stop_reason: None,
737 }),
738 };
739 let tools = extract_tool_uses(&entry);
740 assert_eq!(tools[0].1["file_path"], "/src/main.rs");
741 assert_eq!(tools[0].1["old_string"], "foo");
742 }
743}