1use std::collections::HashSet;
7
8use chrono::DateTime;
9use khive_runtime::secret_gate;
10use serde_json::{Map, Value};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct ParsedEvent {
15 pub uuid: String,
22 pub session_id: String,
24 pub parent_uuid: Option<String>,
26 pub is_sidechain: bool,
28 pub role: Option<String>,
30 pub msg_type: String,
32 pub text: Option<String>,
34 pub raw: String,
36 pub created_at_micros: i64,
38 pub cwd: Option<String>,
40 pub git_branch: Option<String>,
42 pub slug: Option<String>,
45}
46
47pub fn parse_cc_line(line: &str) -> Option<ParsedEvent> {
57 let trimmed = line.trim();
58 if trimmed.is_empty() {
59 return None;
60 }
61
62 let obj: Value = serde_json::from_str(trimmed).ok()?;
63 let map = obj.as_object()?;
64
65 let uuid = map.get("uuid")?.as_str()?.to_string();
67 if uuid.is_empty() {
68 return None;
69 }
70 let session_id = map.get("sessionId")?.as_str()?.to_string();
71 if session_id.is_empty() {
72 return None;
73 }
74
75 let parent_uuid = map
76 .get("parentUuid")
77 .and_then(|v| v.as_str())
78 .filter(|s| !s.is_empty())
79 .map(str::to_string);
80
81 let is_sidechain = map
82 .get("isSidechain")
83 .and_then(|v| v.as_bool())
84 .unwrap_or(false);
85
86 let msg_type = map
87 .get("type")
88 .and_then(|v| v.as_str())
89 .unwrap_or("unknown")
90 .to_string();
91
92 let cwd = map.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
93
94 let git_branch = map
95 .get("gitBranch")
96 .and_then(|v| v.as_str())
97 .map(str::to_string);
98
99 let slug = map.get("slug").and_then(|v| v.as_str()).map(str::to_string);
100
101 let created_at_micros = map
102 .get("timestamp")
103 .and_then(|v| v.as_str())
104 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
105 .map(|dt| dt.timestamp_micros())
106 .unwrap_or(0);
107
108 let (role, text) = match map.get("message").and_then(|m| m.as_object()) {
110 None => (None, None),
111 Some(msg) => {
112 let role = msg.get("role").and_then(|v| v.as_str()).map(str::to_string);
113 let text = extract_text(msg.get("content"));
114 (role, text)
115 }
116 };
117
118 let raw = secret_gate::mask_secrets(trimmed).into_owned();
122 let text = text.map(|t| secret_gate::mask_secrets(&t).into_owned());
123
124 Some(ParsedEvent {
125 uuid,
126 session_id,
127 parent_uuid,
128 is_sidechain,
129 role,
130 msg_type,
131 text,
132 raw,
133 created_at_micros,
134 cwd,
135 git_branch,
136 slug,
137 })
138}
139
140pub fn parse_codex_line(line: &str, session_id: &str, abs_byte_offset: u64) -> Option<ParsedEvent> {
157 let trimmed = line.trim();
158 if trimmed.is_empty() {
159 return None;
160 }
161
162 let obj: Value = serde_json::from_str(trimmed).ok()?;
163 let map = obj.as_object()?;
164
165 let line_type = map.get("type")?.as_str()?;
166
167 if line_type == "event_msg" {
169 return None;
170 }
171
172 let created_at_micros = map
173 .get("timestamp")
174 .and_then(|v| v.as_str())
175 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
176 .map(|dt| dt.timestamp_micros())
177 .unwrap_or(0);
178
179 match line_type {
180 "session_meta" => {
181 let payload = map.get("payload").and_then(|v| v.as_object());
186
187 let cwd = payload
188 .and_then(|p| p.get("cwd"))
189 .and_then(|v| v.as_str())
190 .map(str::to_string);
191
192 let git_branch = payload
193 .and_then(|p| p.get("git"))
194 .and_then(|g| g.as_object())
195 .and_then(|g| g.get("branch"))
196 .and_then(|v| v.as_str())
197 .map(str::to_string);
198
199 let uuid = format!("{session_id}:{abs_byte_offset}");
200 let raw = secret_gate::mask_secrets(trimmed).into_owned();
201
202 Some(ParsedEvent {
203 uuid,
204 session_id: session_id.to_string(),
205 parent_uuid: None,
206 is_sidechain: false,
207 role: None,
208 msg_type: "session_meta".to_string(),
209 text: None,
210 raw,
211 created_at_micros,
212 cwd,
213 git_branch,
214 slug: None,
215 })
216 }
217 "response_item" => {
218 let payload = map.get("payload").and_then(|v| v.as_object())?;
219
220 if payload.get("type").and_then(|v| v.as_str()) != Some("message") {
222 return None;
223 }
224
225 let role = payload
226 .get("role")
227 .and_then(|v| v.as_str())
228 .map(str::to_string);
229
230 let text = extract_text(payload.get("content"));
231 let text = text.map(|t| {
232 let masked = secret_gate::mask_secrets(&t).into_owned();
233 truncate(&masked, 500)
234 });
235
236 let uuid = format!("{session_id}:{abs_byte_offset}");
237 let raw = secret_gate::mask_secrets(trimmed).into_owned();
238
239 Some(ParsedEvent {
240 uuid,
241 session_id: session_id.to_string(),
242 parent_uuid: None,
243 is_sidechain: false,
244 role,
245 msg_type: "response_item".to_string(),
246 text,
247 raw,
248 created_at_micros,
249 cwd: None,
250 git_branch: None,
251 slug: None,
252 })
253 }
254 _ => None,
256 }
257}
258
259pub fn parse_chatgpt_export(content: &str) -> Option<Vec<ParsedEvent>> {
273 let value: Value = serde_json::from_str(content).ok()?;
274 let conversations = value.as_array()?;
275
276 let mut events = Vec::new();
277 for conv in conversations {
278 parse_conversation(conv, &mut events);
279 }
280 Some(events)
281}
282
283fn extract_text(content: Option<&Value>) -> Option<String> {
286 match content? {
287 Value::String(s) => Some(s.clone()),
288 Value::Array(blocks) => {
289 let parts: Vec<String> = blocks.iter().filter_map(extract_block).collect();
290 if parts.is_empty() {
291 None
292 } else {
293 Some(parts.join("\n"))
294 }
295 }
296 _ => None,
297 }
298}
299
300fn extract_block(block: &Value) -> Option<String> {
304 let map = block.as_object()?;
305 match map.get("type")?.as_str()? {
306 "text" | "input_text" | "output_text" => {
309 map.get("text").and_then(|v| v.as_str()).map(str::to_string)
310 }
311 "tool_use" => {
312 let name = map
313 .get("name")
314 .and_then(|v| v.as_str())
315 .unwrap_or("unknown");
316 let input = map.get("input").cloned().unwrap_or(Value::Null);
317 let input_str = truncate(&serde_json::to_string(&input).unwrap_or_default(), 500);
318 Some(format!("[tool_use: {name}] {input_str}"))
319 }
320 "tool_result" => {
321 let content_val = map.get("content").cloned().unwrap_or(Value::Null);
322 let content_str = match &content_val {
323 Value::String(s) => s.clone(),
324 other => serde_json::to_string(other).unwrap_or_default(),
325 };
326 Some(format!("[tool_result] {}", truncate(&content_str, 500)))
327 }
328 _ => None,
329 }
330}
331
332fn truncate(s: &str, max_chars: usize) -> String {
334 if s.chars().count() <= max_chars {
335 s.to_string()
336 } else {
337 let mut out: String = s.chars().take(max_chars).collect();
338 out.push('…');
339 out
340 }
341}
342
343struct ConvContext<'a> {
346 mapping: &'a Map<String, Value>,
347 current_path: &'a HashSet<String>,
348 session_id: &'a str,
349 conv_created_at_micros: i64,
350 slug: Option<&'a str>,
351}
352
353fn parse_conversation(conv: &Value, out: &mut Vec<ParsedEvent>) {
358 let Some(conv_obj) = conv.as_object() else {
359 return;
360 };
361 let Some(session_id) = conv_obj
362 .get("id")
363 .and_then(|v| v.as_str())
364 .filter(|s| !s.is_empty())
365 else {
366 return;
367 };
368 let Some(mapping) = conv_obj.get("mapping").and_then(|v| v.as_object()) else {
369 return;
370 };
371
372 let slug = conv_obj
373 .get("title")
374 .and_then(|v| v.as_str())
375 .filter(|s| !s.is_empty())
376 .map(str::to_string);
377
378 let conv_created_at_micros = conv_obj
379 .get("create_time")
380 .and_then(|v| v.as_f64())
381 .map(|secs| (secs * 1_000_000.0) as i64)
382 .unwrap_or(0);
383
384 let mut current_path: HashSet<String> = HashSet::new();
387 if let Some(current_node) = conv_obj.get("current_node").and_then(|v| v.as_str()) {
388 let mut cursor = Some(current_node.to_string());
389 while let Some(node_id) = cursor {
390 if !current_path.insert(node_id.clone()) {
391 break; }
393 cursor = mapping
394 .get(&node_id)
395 .and_then(|n| n.as_object())
396 .and_then(|n| n.get("parent"))
397 .and_then(|v| v.as_str())
398 .map(str::to_string);
399 }
400 }
401
402 let root_id = mapping.iter().find_map(|(id, node)| {
403 let node_obj = node.as_object()?;
404 let parent_is_null = node_obj.get("parent").map(|v| v.is_null()).unwrap_or(true);
405 parent_is_null.then(|| id.clone())
406 });
407 let Some(root_id) = root_id else {
408 return;
409 };
410
411 let ctx = ConvContext {
412 mapping,
413 current_path: ¤t_path,
414 session_id,
415 conv_created_at_micros,
416 slug: slug.as_deref(),
417 };
418
419 let mut stack: Vec<String> = vec![root_id];
421 let mut visited: HashSet<String> = HashSet::new();
422
423 while let Some(node_id) = stack.pop() {
424 if !visited.insert(node_id.clone()) {
425 continue; }
427 let Some(node) = mapping.get(&node_id).and_then(|n| n.as_object()) else {
428 continue;
429 };
430
431 if let Some(message) = node.get("message").filter(|m| !m.is_null()) {
432 if let Some(message_obj) = message.as_object() {
433 if let Some(ev) = build_chatgpt_event(&node_id, node, message_obj, &ctx) {
434 out.push(ev);
435 }
436 }
437 }
438
439 if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
440 for child in children.iter().rev() {
443 if let Some(child_id) = child.as_str() {
444 stack.push(child_id.to_string());
445 }
446 }
447 }
448 }
449}
450
451fn build_chatgpt_event(
456 node_id: &str,
457 node: &Map<String, Value>,
458 message: &Map<String, Value>,
459 ctx: &ConvContext,
460) -> Option<ParsedEvent> {
461 let uuid = message
462 .get("id")
463 .and_then(|v| v.as_str())
464 .filter(|s| !s.is_empty())?
465 .to_string();
466
467 let role = message
468 .get("author")
469 .and_then(|a| a.as_object())
470 .and_then(|a| a.get("role"))
471 .and_then(|v| v.as_str())
472 .map(str::to_string);
473
474 let content = message.get("content").and_then(|c| c.as_object());
475 let content_type = content
476 .and_then(|c| c.get("content_type"))
477 .and_then(|v| v.as_str())
478 .unwrap_or("unknown")
479 .to_string();
480
481 let text = extract_chatgpt_text(&content_type, content)?;
482 if text.trim().is_empty() {
483 return None;
484 }
485
486 let created_at_micros = message
487 .get("create_time")
488 .and_then(|v| v.as_f64())
489 .map(|secs| (secs * 1_000_000.0) as i64)
490 .unwrap_or(ctx.conv_created_at_micros);
491
492 let parent_uuid = node
495 .get("parent")
496 .and_then(|v| v.as_str())
497 .filter(|pid| {
498 ctx.mapping
499 .get(*pid)
500 .and_then(|p| p.as_object())
501 .and_then(|p| p.get("message"))
502 .map(|m| !m.is_null())
503 .unwrap_or(false)
504 })
505 .map(str::to_string);
506
507 let is_sidechain = !ctx.current_path.contains(node_id);
508
509 let raw_json = serde_json::to_string(node).unwrap_or_default();
510 let raw = secret_gate::mask_secrets(&raw_json).into_owned();
511 let text = secret_gate::mask_secrets(&text).into_owned();
512
513 Some(ParsedEvent {
514 uuid,
515 session_id: ctx.session_id.to_string(),
516 parent_uuid,
517 is_sidechain,
518 role,
519 msg_type: content_type,
520 text: Some(text),
521 raw,
522 created_at_micros,
523 cwd: None,
524 git_branch: None,
525 slug: ctx.slug.map(str::to_string),
526 })
527}
528
529fn extract_chatgpt_text(
533 content_type: &str,
534 content: Option<&Map<String, Value>>,
535) -> Option<String> {
536 let content = content?;
537
538 if content_type == "text" {
539 let parts = content.get("parts")?.as_array()?;
540 let joined: Vec<String> = parts
541 .iter()
542 .filter_map(|p| p.as_str().map(str::to_string))
543 .collect();
544 return if joined.is_empty() {
545 None
546 } else {
547 Some(joined.join("\n"))
548 };
549 }
550
551 if let Some(text) = content.get("text").and_then(|v| v.as_str()) {
552 return Some(text.to_string());
553 }
554
555 let parts = content.get("parts")?.as_array()?;
556 let joined: Vec<String> = parts
557 .iter()
558 .filter_map(|p| p.as_str().map(str::to_string))
559 .collect();
560 if joined.is_empty() {
561 None
562 } else {
563 Some(joined.join("\n"))
564 }
565}
566
567#[cfg(test)]
570mod tests {
571 use super::*;
572
573 fn make_line(uuid: &str, session_id: &str, type_: &str, extra: &str) -> String {
575 format!(
576 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"{type_}","timestamp":"2026-06-29T10:00:00Z"{extra}}}"#,
577 )
578 }
579
580 #[test]
581 fn test_blank_line_returns_none() {
582 assert!(parse_cc_line("").is_none());
583 assert!(parse_cc_line(" ").is_none());
584 }
585
586 #[test]
587 fn test_no_uuid_returns_none() {
588 let line = r#"{"type":"pr-link","sessionId":"sess-1","url":"https://github.com/foo"}"#;
590 assert!(parse_cc_line(line).is_none());
591 }
592
593 #[test]
594 fn test_no_session_id_returns_none() {
595 let line = r#"{"uuid":"aaaa-bbbb","type":"user"}"#;
596 assert!(parse_cc_line(line).is_none());
597 }
598
599 #[test]
600 fn test_user_text_line() {
601 let line = make_line(
602 "aaaa-bbbb",
603 "sess-1111",
604 "user",
605 r#","message":{"role":"user","content":"Hello world"},"cwd":"/proj","gitBranch":"main","slug":"my-proj""#,
606 );
607 let ev = parse_cc_line(&line).expect("should parse");
608 assert_eq!(ev.uuid, "aaaa-bbbb");
609 assert_eq!(ev.session_id, "sess-1111");
610 assert_eq!(ev.role.as_deref(), Some("user"));
611 assert_eq!(ev.msg_type, "user");
612 assert_eq!(ev.text.as_deref(), Some("Hello world"));
613 assert_eq!(ev.cwd.as_deref(), Some("/proj"));
614 assert_eq!(ev.git_branch.as_deref(), Some("main"));
615 assert_eq!(ev.slug.as_deref(), Some("my-proj"));
616 assert!(ev.created_at_micros > 0);
617 assert!(!ev.is_sidechain);
618 }
619
620 #[test]
621 fn test_assistant_with_text_and_tool_use_blocks() {
622 let line = r#"{"uuid":"cccc-dddd","sessionId":"sess-1111","type":"assistant","timestamp":"2026-06-29T10:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll run a search."},{"type":"tool_use","name":"bash","input":{"command":"ls"}}]}}"#
623 .to_string();
624 let ev = parse_cc_line(&line).expect("should parse");
625 assert_eq!(ev.role.as_deref(), Some("assistant"));
626 let text = ev.text.expect("text should be present");
627 assert!(text.contains("I'll run a search."), "text: {text}");
628 assert!(text.contains("[tool_use: bash]"), "text: {text}");
629 assert!(text.contains("command"), "text: {text}");
630 }
631
632 #[test]
633 fn test_tool_result_block() {
634 let line = r#"{"uuid":"eeee-ffff","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:02:00Z","message":{"role":"user","content":[{"type":"tool_result","content":"file1.rs\nfile2.rs"}]}}"#
635 .to_string();
636 let ev = parse_cc_line(&line).expect("should parse");
637 let text = ev.text.expect("text should be present");
638 assert!(text.contains("[tool_result]"), "text: {text}");
639 assert!(text.contains("file1.rs"), "text: {text}");
640 }
641
642 #[test]
643 fn test_attachment_line_no_message() {
644 let line = r#"{"uuid":"gggg-hhhh","sessionId":"sess-1111","type":"attachment","timestamp":"2026-06-29T10:02:00Z","filename":"file.txt"}"#
646 .to_string();
647 let ev = parse_cc_line(&line).expect("should parse");
648 assert_eq!(ev.msg_type, "attachment");
649 assert!(ev.role.is_none());
650 assert!(ev.text.is_none());
651 }
652
653 #[test]
654 fn test_secret_masking_in_text_and_raw() {
655 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
656 let line = format!(
657 r#"{{"uuid":"iiii-jjjj","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:03:00Z","message":{{"role":"user","content":"my key is {secret}"}}}}"#
658 );
659 let ev = parse_cc_line(&line).expect("should parse");
660
661 let text = ev.text.expect("text should be present");
662 assert!(
663 !text.contains(secret),
664 "secret must not appear in text: {text}"
665 );
666 assert!(
667 text.contains("***MASKED***"),
668 "MASKED marker must appear in text: {text}"
669 );
670
671 assert!(
672 !ev.raw.contains(secret),
673 "secret must not appear in raw: {}",
674 ev.raw
675 );
676 assert!(
677 ev.raw.contains("***MASKED***"),
678 "MASKED marker must appear in raw: {}",
679 ev.raw
680 );
681 }
682
683 #[test]
684 fn test_github_pat_masked() {
685 let secret = "github_pat_ABCDE12345fghij67890KLMNO";
686 let line = format!(
687 r#"{{"uuid":"kkkk-llll","sessionId":"sess-2","type":"user","timestamp":"2026-06-29T10:04:00Z","message":{{"role":"user","content":"token={secret}"}}}}"#
688 );
689 let ev = parse_cc_line(&line).unwrap();
690 assert!(!ev.raw.contains(secret));
691 assert!(ev.raw.contains("***MASKED***"));
692 }
693
694 #[test]
695 fn test_timestamp_to_micros() {
696 let line = make_line(
697 "ts-test",
698 "sess-ts",
699 "system",
700 r#","timestamp":"2026-06-29T17:56:01.123Z""#,
701 );
702 let ev = parse_cc_line(&line).unwrap();
703 assert!(ev.created_at_micros > 0, "created_at_micros should be > 0");
705 }
706
707 #[test]
708 fn test_sidechain_flag() {
709 let line = make_line("side-uuid", "sess-side", "user", r#","isSidechain":true"#);
710 let ev = parse_cc_line(&line).unwrap();
711 assert!(ev.is_sidechain);
712 }
713
714 const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
717
718 fn codex_user_msg(text: &str) -> String {
720 format!(
721 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#
722 )
723 }
724
725 fn codex_asst_msg(text: &str) -> String {
727 format!(
728 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"{text}"}}]}}}}"#
729 )
730 }
731
732 fn codex_meta(cwd: &str, branch: &str) -> String {
734 format!(
735 r#"{{"type":"session_meta","timestamp":"2026-06-30T09:00:00Z","payload":{{"id":"{CDX_SID}","cwd":"{cwd}","git":{{"branch":"{branch}","commit_hash":"abc123","repository_url":"https://github.com/example/repo"}}}}}}"#
736 )
737 }
738
739 fn codex_tool_use_msg() -> String {
741 r#"{"type":"response_item","timestamp":"2026-06-30T09:01:00Z","payload":{"type":"message","role":"assistant","content":[{"type":"tool_use","name":"bash","input":{"command":"cargo test"}}]}}"#.to_string()
742 }
743
744 fn codex_event_msg() -> String {
746 r#"{"type":"event_msg","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"user_message","content":"duplicate"}}"#.to_string()
747 }
748
749 #[test]
750 fn test_codex_blank_returns_none() {
751 assert!(parse_codex_line("", CDX_SID, 0).is_none());
752 assert!(parse_codex_line(" ", CDX_SID, 0).is_none());
753 }
754
755 #[test]
756 fn test_codex_event_msg_skipped() {
757 let line = codex_event_msg();
758 assert!(
759 parse_codex_line(&line, CDX_SID, 42).is_none(),
760 "event_msg must be skipped"
761 );
762 }
763
764 #[test]
765 fn test_codex_unknown_type_skipped() {
766 let line = r#"{"type":"some_other_event","timestamp":"2026-06-30T09:00:00Z","payload":{}}"#;
767 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
768 }
769
770 #[test]
771 fn test_codex_session_meta_produces_event() {
772 let line = codex_meta("/workspace/proj", "main");
773 let ev = parse_codex_line(&line, CDX_SID, 0).expect("session_meta should parse");
774 assert_eq!(ev.session_id, CDX_SID);
775 assert_eq!(ev.msg_type, "session_meta");
776 assert_eq!(ev.cwd.as_deref(), Some("/workspace/proj"));
777 assert_eq!(ev.git_branch.as_deref(), Some("main"));
778 assert!(ev.role.is_none());
779 assert!(ev.text.is_none());
780 assert_eq!(ev.uuid, format!("{CDX_SID}:0"));
782 assert!(ev.created_at_micros > 0);
783 }
784
785 #[test]
786 fn test_codex_user_message_input_text_block() {
787 let line = codex_user_msg("Hello Codex");
789 let ev = parse_codex_line(&line, CDX_SID, 128).expect("user message should parse");
790 assert_eq!(ev.session_id, CDX_SID);
791 assert_eq!(ev.msg_type, "response_item");
792 assert_eq!(ev.role.as_deref(), Some("user"));
793 let text = ev.text.expect("text must be non-NULL for input_text block");
795 assert_eq!(text, "Hello Codex");
796 assert_eq!(ev.uuid, format!("{CDX_SID}:128"));
797 }
798
799 #[test]
800 fn test_codex_assistant_message_output_text_block() {
801 let line = codex_asst_msg("Hello from assistant");
803 let ev = parse_codex_line(&line, CDX_SID, 256).expect("assistant message should parse");
804 assert_eq!(ev.role.as_deref(), Some("assistant"));
805 let text = ev
807 .text
808 .expect("text must be non-NULL for output_text block");
809 assert_eq!(text, "Hello from assistant");
810 assert_eq!(ev.uuid, format!("{CDX_SID}:256"));
811 }
812
813 #[test]
814 fn test_codex_tool_use_block_extracted() {
815 let line = codex_tool_use_msg();
816 let ev = parse_codex_line(&line, CDX_SID, 512).expect("tool_use message should parse");
817 assert_eq!(ev.role.as_deref(), Some("assistant"));
818 let text = ev.text.expect("text must be present");
819 assert!(text.contains("[tool_use: bash]"), "text: {text}");
820 assert!(text.contains("cargo test"), "text: {text}");
821 }
822
823 #[test]
824 fn test_codex_text_truncated_at_500_chars() {
825 let long_text = "x".repeat(600);
827 let line = codex_user_msg(&long_text);
828 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
829 let text = ev.text.expect("text must be present");
830 assert!(
832 text.chars().count() <= 501,
833 "text must be truncated: len={}",
834 text.chars().count()
835 );
836 assert!(text.ends_with('…'), "truncated text must end with ellipsis");
837 }
838
839 #[test]
840 fn test_codex_secret_masked_in_text_and_raw() {
841 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
843 let line = codex_user_msg(secret);
844 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
845 let text = ev.text.expect("text present");
846 assert!(!text.contains(secret), "secret must not appear in text");
847 assert!(
848 text.contains("***MASKED***"),
849 "MASKED marker must appear in text"
850 );
851 assert!(!ev.raw.contains(secret), "secret must not appear in raw");
852 assert!(
853 ev.raw.contains("***MASKED***"),
854 "MASKED marker must appear in raw"
855 );
856 }
857
858 #[test]
859 fn test_codex_synthetic_uuid_stable_across_calls() {
860 let line = codex_user_msg("consistency");
863 let ev1 = parse_codex_line(&line, CDX_SID, 999).unwrap();
864 let ev2 = parse_codex_line(&line, CDX_SID, 999).unwrap();
865 assert_eq!(ev1.uuid, ev2.uuid);
866 assert_eq!(ev1.uuid, format!("{CDX_SID}:999"));
867 }
868
869 #[test]
870 fn test_codex_response_item_non_message_payload_skipped() {
871 let line = r#"{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"tool_call","name":"some_tool"}}"#;
873 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
874 }
875
876 #[test]
877 fn test_cc_text_block_still_works() {
878 let line = r#"{"uuid":"cc-t1","sessionId":"cc-sess","type":"assistant","timestamp":"2026-06-30T09:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"CC still works"}]}}"#;
881 let ev = parse_cc_line(line).expect("CC text block must parse");
882 assert_eq!(ev.text.as_deref(), Some("CC still works"));
883 }
884
885 #[test]
892 fn test_chatgpt_export_minimal_valid_export() {
893 let export = serde_json::json!([{
894 "id": "conv-min",
895 "title": "Minimal Export",
896 "current_node": "msg-assistant",
897 "create_time": 1_700_000_000.0,
898 "mapping": {
899 "root": {
900 "id": "root",
901 "message": null,
902 "parent": null,
903 "children": ["msg-user"]
904 },
905 "msg-user": {
906 "id": "msg-user",
907 "parent": "root",
908 "children": ["msg-assistant"],
909 "message": {
910 "id": "msg-user",
911 "author": {"role": "user"},
912 "content": {"content_type": "text", "parts": ["Hello"]}
913 }
914 },
915 "msg-assistant": {
916 "id": "msg-assistant",
917 "parent": "msg-user",
918 "children": [],
919 "message": {
920 "id": "msg-assistant",
921 "author": {"role": "assistant"},
922 "content": {"content_type": "text", "parts": ["Hi there"]}
923 }
924 }
925 }
926 }]);
927 let content = serde_json::to_string(&export).unwrap();
928
929 let events = parse_chatgpt_export(&content).expect("valid export must parse");
930 assert_eq!(events.len(), 2, "root has no message, 2 nodes carry one");
931
932 assert_eq!(events[0].uuid, "msg-user");
933 assert_eq!(events[0].session_id, "conv-min");
934 assert_eq!(events[0].role.as_deref(), Some("user"));
935 assert_eq!(events[0].text.as_deref(), Some("Hello"));
936 assert_eq!(events[0].parent_uuid, None, "root carries no message");
937 assert!(!events[0].is_sidechain);
938 assert_eq!(events[0].slug.as_deref(), Some("Minimal Export"));
939
940 assert_eq!(events[1].uuid, "msg-assistant");
941 assert_eq!(events[1].role.as_deref(), Some("assistant"));
942 assert_eq!(events[1].text.as_deref(), Some("Hi there"));
943 assert_eq!(events[1].parent_uuid.as_deref(), Some("msg-user"));
944 assert!(!events[1].is_sidechain);
945 }
946
947 #[test]
948 fn test_chatgpt_export_multi_branch_tree_dfs_order_and_sidechain() {
949 let export = serde_json::json!([{
951 "id": "conv-branch",
952 "title": "Branching Conversation",
953 "current_node": "msg-main",
954 "mapping": {
955 "root": {
956 "id": "root",
957 "message": null,
958 "parent": null,
959 "children": ["msg-user"]
960 },
961 "msg-user": {
962 "id": "msg-user",
963 "parent": "root",
964 "children": ["msg-main", "msg-alt"],
965 "message": {
966 "id": "msg-user",
967 "author": {"role": "user"},
968 "content": {"content_type": "text", "parts": ["Question"]}
969 }
970 },
971 "msg-main": {
972 "id": "msg-main",
973 "parent": "msg-user",
974 "children": [],
975 "message": {
976 "id": "msg-main",
977 "author": {"role": "assistant"},
978 "content": {"content_type": "text", "parts": ["Main answer"]}
979 }
980 },
981 "msg-alt": {
982 "id": "msg-alt",
983 "parent": "msg-user",
984 "children": [],
985 "message": {
986 "id": "msg-alt",
987 "author": {"role": "assistant"},
988 "content": {"content_type": "text", "parts": ["Alternate answer"]}
989 }
990 }
991 }
992 }]);
993 let content = serde_json::to_string(&export).unwrap();
994
995 let events = parse_chatgpt_export(&content).expect("branch export must parse");
996 assert_eq!(events.len(), 3);
997
998 let uuids: Vec<&str> = events.iter().map(|e| e.uuid.as_str()).collect();
1001 assert_eq!(uuids, vec!["msg-user", "msg-main", "msg-alt"]);
1002
1003 let by_uuid = |id: &str| events.iter().find(|e| e.uuid == id).unwrap();
1004 assert!(!by_uuid("msg-user").is_sidechain);
1005 assert!(
1006 !by_uuid("msg-main").is_sidechain,
1007 "current_node's own path must not be flagged"
1008 );
1009 assert!(
1010 by_uuid("msg-alt").is_sidechain,
1011 "branch off current_node path must be flagged sidechain"
1012 );
1013 assert_eq!(by_uuid("msg-alt").text.as_deref(), Some("Alternate answer"));
1014 }
1015
1016 #[test]
1017 fn test_chatgpt_export_malformed_inputs() {
1018 assert!(parse_chatgpt_export("not json").is_none());
1020
1021 assert!(parse_chatgpt_export(r#"{"oops": "not an array"}"#).is_none());
1023
1024 let export = serde_json::json!([
1028 {"id": "no-mapping"},
1029 {"mapping": {}},
1030 "not-an-object",
1031 {
1032 "id": "conv-good",
1033 "current_node": "msg-good",
1034 "mapping": {
1035 "root": {"id": "root", "message": null, "parent": null, "children": ["msg-good"]},
1036 "msg-good": {
1037 "id": "msg-good",
1038 "parent": "root",
1039 "children": [],
1040 "message": {
1041 "id": "msg-good",
1042 "author": {"role": "user"},
1043 "content": {"content_type": "text", "parts": ["Still works"]}
1044 }
1045 }
1046 }
1047 }
1048 ]);
1049 let content = serde_json::to_string(&export).unwrap();
1050
1051 let events = parse_chatgpt_export(&content)
1052 .expect("array with some malformed conversations still parses");
1053 assert_eq!(
1054 events.len(),
1055 1,
1056 "malformed conversations skipped, valid one still yields its event"
1057 );
1058 assert_eq!(events[0].uuid, "msg-good");
1059 assert_eq!(events[0].session_id, "conv-good");
1060 }
1061}