1use super::types::TranscriptRecord;
7
8pub fn parse_jsonl_line(line: &str) -> Vec<TranscriptRecord> {
13 let line = line.trim();
14 if line.is_empty() {
15 return Vec::new();
16 }
17
18 let value: serde_json::Value = match serde_json::from_str(line) {
19 Ok(v) => v,
20 Err(_) => return Vec::new(),
21 };
22
23 let uuid = value.get("uuid").and_then(|v| v.as_str()).map(String::from);
25 let timestamp = value
26 .get("timestamp")
27 .and_then(|v| v.as_str())
28 .map(String::from);
29
30 let msg_type = match value.get("type").and_then(|t| t.as_str()) {
31 Some(t) => t,
32 None => return Vec::new(),
33 };
34
35 match msg_type {
36 "user" => parse_user_message(&value, uuid, timestamp),
37 "assistant" => parse_assistant_message(&value, uuid, timestamp),
38 _ => Vec::new(),
39 }
40}
41
42fn parse_user_message(
47 value: &serde_json::Value,
48 uuid: Option<String>,
49 timestamp: Option<String>,
50) -> Vec<TranscriptRecord> {
51 let message = match value.get("message") {
52 Some(m) => m,
53 None => return Vec::new(),
54 };
55 let content = match message.get("content") {
56 Some(c) => c,
57 None => return Vec::new(),
58 };
59
60 if let Some(s) = content.as_str() {
62 if s.is_empty() {
63 return Vec::new();
64 }
65 return vec![TranscriptRecord::User {
66 text: s.to_string(),
67 uuid,
68 timestamp,
69 }];
70 }
71
72 let arr = match content.as_array() {
74 Some(a) => a,
75 None => return Vec::new(),
76 };
77
78 let mut records = Vec::new();
79 let mut text_parts = Vec::new();
80
81 for block in arr {
82 let block_type = match block.get("type").and_then(|t| t.as_str()) {
83 Some(t) => t,
84 None => continue,
85 };
86
87 match block_type {
88 "text" => {
89 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
90 if !text.is_empty() {
91 text_parts.push(text.to_string());
92 }
93 }
94 }
95 "tool_result" => {
96 let output = extract_tool_result_content(block);
97 if !output.is_empty() {
98 let is_error = block.get("is_error").and_then(|v| v.as_bool());
99 records.push(TranscriptRecord::ToolResult {
100 output_summary: truncate_for_preview(&output, 200),
101 is_error,
102 uuid: uuid.clone(),
103 timestamp: timestamp.clone(),
104 });
105 }
106 }
107 _ => {}
108 }
109 }
110
111 if !text_parts.is_empty() {
112 records.insert(
114 0,
115 TranscriptRecord::User {
116 text: text_parts.join("\n"),
117 uuid: uuid.clone(),
118 timestamp: timestamp.clone(),
119 },
120 );
121 }
122
123 records
124}
125
126fn extract_tool_result_content(block: &serde_json::Value) -> String {
128 let content = match block.get("content") {
129 Some(c) => c,
130 None => return String::new(),
131 };
132
133 if let Some(s) = content.as_str() {
134 return s.to_string();
135 }
136
137 if let Some(arr) = content.as_array() {
138 return arr
139 .iter()
140 .filter_map(|b| {
141 if b.get("type")?.as_str()? == "text" {
142 b.get("text")?.as_str().map(|s| s.to_string())
143 } else {
144 None
145 }
146 })
147 .collect::<Vec<_>>()
148 .join("\n");
149 }
150
151 String::new()
152}
153
154fn parse_assistant_message(
161 value: &serde_json::Value,
162 uuid: Option<String>,
163 timestamp: Option<String>,
164) -> Vec<TranscriptRecord> {
165 let message = match value.get("message") {
166 Some(m) => m,
167 None => return Vec::new(),
168 };
169 let content = match message.get("content").and_then(|c| c.as_array()) {
170 Some(a) => a,
171 None => return Vec::new(),
172 };
173
174 let mut records = Vec::new();
175
176 for block in content {
177 let block_type = match block.get("type").and_then(|t| t.as_str()) {
178 Some(t) => t,
179 None => continue,
180 };
181
182 match block_type {
183 "text" => {
184 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
185 if !text.is_empty() {
186 records.push(TranscriptRecord::AssistantText {
187 text: text.to_string(),
188 uuid: uuid.clone(),
189 timestamp: timestamp.clone(),
190 });
191 }
192 }
193 }
194 "thinking" => {
195 if let Some(text) = block.get("thinking").and_then(|t| t.as_str()) {
196 if !text.is_empty() {
197 records.push(TranscriptRecord::Thinking {
198 text: text.to_string(),
199 uuid: uuid.clone(),
200 timestamp: timestamp.clone(),
201 });
202 }
203 }
204 }
205 "tool_use" => {
206 let tool_name = block
207 .get("name")
208 .and_then(|n| n.as_str())
209 .unwrap_or("Unknown")
210 .to_string();
211 let input = block.get("input");
212 let input_summary = summarize_tool_input_json(&tool_name, input);
213 let input_full = input.cloned();
214 records.push(TranscriptRecord::ToolUse {
215 tool_name,
216 input_summary,
217 input_full,
218 uuid: uuid.clone(),
219 timestamp: timestamp.clone(),
220 });
221 }
222 _ => {}
223 }
224 }
225
226 records
227}
228
229fn summarize_tool_input_json(tool_name: &str, input: Option<&serde_json::Value>) -> String {
231 let input = match input {
232 Some(v) => v,
233 None => return String::new(),
234 };
235
236 let key = match tool_name {
237 "Bash" => "command",
238 "Edit" | "Read" | "Write" => "file_path",
239 "Grep" => "pattern",
240 "Glob" => "pattern",
241 "Agent" => "description",
242 _ => "command",
243 };
244
245 input
246 .get(key)
247 .and_then(|v| v.as_str())
248 .map(|s| truncate_for_preview(s, 80))
249 .unwrap_or_default()
250}
251
252fn truncate_for_preview(s: &str, max_len: usize) -> String {
255 let first_line = s.lines().next().unwrap_or(s);
256 let char_count = first_line.chars().count();
257 if char_count > max_len {
258 let truncated: String = first_line.chars().take(max_len).collect();
259 format!("{}...", truncated)
260 } else {
261 first_line.to_string()
262 }
263}
264
265pub fn extract_model_id(path: &str) -> Option<String> {
270 let file = std::fs::File::open(path).ok()?;
271 let reader = std::io::BufReader::new(file);
272 for line in std::io::BufRead::lines(reader).take(20) {
274 let line = line.ok()?;
275 let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
276 if value.get("type")?.as_str()? == "assistant" {
277 if let Some(model) = value
278 .get("message")
279 .and_then(|m| m.get("model"))
280 .and_then(|m| m.as_str())
281 {
282 return Some(model.to_string());
283 }
284 }
285 }
286 None
287}
288
289pub fn model_display_name(model_id: &str) -> String {
291 if model_id.contains("opus") {
293 if model_id.contains("4-6") {
294 "Opus 4.6".to_string()
295 } else if model_id.contains("4-5") {
296 "Opus 4.5".to_string()
297 } else {
298 "Opus".to_string()
299 }
300 } else if model_id.contains("sonnet") {
301 if model_id.contains("4-6") {
302 "Sonnet 4.6".to_string()
303 } else if model_id.contains("4-5") {
304 "Sonnet 4.5".to_string()
305 } else if model_id.contains("3-5") || model_id.contains("3.5") {
306 "Sonnet 3.5".to_string()
307 } else {
308 "Sonnet".to_string()
309 }
310 } else if model_id.contains("haiku") {
311 if model_id.contains("4-5") {
312 "Haiku 4.5".to_string()
313 } else {
314 "Haiku".to_string()
315 }
316 } else {
317 model_id
319 .split(['/', '-'])
320 .next_back()
321 .unwrap_or(model_id)
322 .to_string()
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn test_parse_user_message_string() {
332 let line = r#"{"type":"user","uuid":"abc-123","timestamp":"2026-04-02T12:00:00Z","message":{"content":"Hello world"}}"#;
333 let records = parse_jsonl_line(line);
334 assert_eq!(records.len(), 1);
335 match &records[0] {
336 TranscriptRecord::User {
337 text,
338 uuid,
339 timestamp,
340 } => {
341 assert_eq!(text, "Hello world");
342 assert_eq!(uuid.as_deref(), Some("abc-123"));
343 assert_eq!(timestamp.as_deref(), Some("2026-04-02T12:00:00Z"));
344 }
345 _ => panic!("Expected User record"),
346 }
347 }
348
349 #[test]
350 fn test_parse_user_message_array() {
351 let line = r#"{"type":"user","message":{"content":[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]}}"#;
352 let records = parse_jsonl_line(line);
353 assert_eq!(records.len(), 1);
354 match &records[0] {
355 TranscriptRecord::User { text, .. } => assert_eq!(text, "Hello\nWorld"),
356 _ => panic!("Expected User record"),
357 }
358 }
359
360 #[test]
361 fn test_parse_user_message_with_tool_result() {
362 let line = r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"tool_result","tool_use_id":"tu1","content":"test output","is_error":false}]}}"#;
363 let records = parse_jsonl_line(line);
364 assert_eq!(records.len(), 1);
365 match &records[0] {
366 TranscriptRecord::ToolResult {
367 output_summary,
368 is_error,
369 ..
370 } => {
371 assert_eq!(output_summary, "test output");
372 assert_eq!(*is_error, Some(false));
373 }
374 _ => panic!("Expected ToolResult record"),
375 }
376 }
377
378 #[test]
379 fn test_parse_assistant_text() {
380 let line = r#"{"type":"assistant","uuid":"a1","timestamp":"2026-04-02T12:01:00Z","message":{"content":[{"type":"text","text":"I'll help you."}]}}"#;
381 let records = parse_jsonl_line(line);
382 assert_eq!(records.len(), 1);
383 match &records[0] {
384 TranscriptRecord::AssistantText {
385 text,
386 uuid,
387 timestamp,
388 } => {
389 assert_eq!(text, "I'll help you.");
390 assert_eq!(uuid.as_deref(), Some("a1"));
391 assert_eq!(timestamp.as_deref(), Some("2026-04-02T12:01:00Z"));
392 }
393 _ => panic!("Expected AssistantText record"),
394 }
395 }
396
397 #[test]
398 fn test_parse_assistant_tool_use() {
399 let line = r#"{"type":"assistant","uuid":"a2","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}]}}"#;
400 let records = parse_jsonl_line(line);
401 assert_eq!(records.len(), 1);
402 match &records[0] {
403 TranscriptRecord::ToolUse {
404 tool_name,
405 input_summary,
406 input_full,
407 ..
408 } => {
409 assert_eq!(tool_name, "Bash");
410 assert_eq!(input_summary, "ls -la");
411 assert!(input_full.is_some());
412 assert_eq!(
413 input_full.as_ref().unwrap().get("command").unwrap(),
414 "ls -la"
415 );
416 }
417 _ => panic!("Expected ToolUse record"),
418 }
419 }
420
421 #[test]
422 fn test_parse_thinking() {
423 let line = r#"{"type":"assistant","uuid":"a3","message":{"content":[{"type":"thinking","thinking":"Let me analyze this..."}]}}"#;
424 let records = parse_jsonl_line(line);
425 assert_eq!(records.len(), 1);
426 match &records[0] {
427 TranscriptRecord::Thinking { text, uuid, .. } => {
428 assert_eq!(text, "Let me analyze this...");
429 assert_eq!(uuid.as_deref(), Some("a3"));
430 }
431 _ => panic!("Expected Thinking record"),
432 }
433 }
434
435 #[test]
436 fn test_parse_empty_line() {
437 assert!(parse_jsonl_line("").is_empty());
438 assert!(parse_jsonl_line(" ").is_empty());
439 }
440
441 #[test]
442 fn test_parse_invalid_json() {
443 assert!(parse_jsonl_line("not json").is_empty());
444 }
445
446 #[test]
447 fn test_parse_unknown_type() {
448 let line = r#"{"type":"system","data":"info"}"#;
449 assert!(parse_jsonl_line(line).is_empty());
450 }
451
452 #[test]
453 fn test_parse_no_uuid_timestamp() {
454 let line = r#"{"type":"user","message":{"content":"Hi"}}"#;
455 let records = parse_jsonl_line(line);
456 assert_eq!(records.len(), 1);
457 match &records[0] {
458 TranscriptRecord::User {
459 uuid, timestamp, ..
460 } => {
461 assert!(uuid.is_none());
462 assert!(timestamp.is_none());
463 }
464 _ => panic!("Expected User record"),
465 }
466 }
467
468 #[test]
469 fn test_model_display_name() {
470 assert_eq!(model_display_name("claude-opus-4-6"), "Opus 4.6");
471 assert_eq!(model_display_name("claude-sonnet-4-6"), "Sonnet 4.6");
472 assert_eq!(
473 model_display_name("claude-sonnet-4-5-20250514"),
474 "Sonnet 4.5"
475 );
476 assert_eq!(model_display_name("claude-haiku-4-5-20251001"), "Haiku 4.5");
477 assert_eq!(model_display_name("claude-opus-4-5-20250918"), "Opus 4.5");
478 assert_eq!(
479 model_display_name("claude-3-5-sonnet-20241022"),
480 "Sonnet 3.5"
481 );
482 assert_eq!(model_display_name("gpt-4o"), "4o");
483 }
484
485 #[test]
486 fn test_extract_model_id_from_file() {
487 use std::io::Write;
488 let tmp = tempfile::NamedTempFile::new().unwrap();
489 let path = tmp.path().to_str().unwrap().to_string();
490 {
491 let mut f = std::fs::File::create(&path).unwrap();
492 writeln!(f, r#"{{"type":"user","message":{{"content":"hi"}}}}"#).unwrap();
493 writeln!(f, r#"{{"type":"assistant","message":{{"model":"claude-opus-4-6","content":[{{"type":"text","text":"hello"}}]}}}}"#).unwrap();
494 }
495 assert_eq!(extract_model_id(&path), Some("claude-opus-4-6".to_string()));
496 }
497}