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>> {
282 let value: Value = serde_json::from_str(content).ok()?;
283 let conversations = value.as_array()?;
284
285 let mut events = Vec::new();
286 for conv in conversations {
287 parse_conversation(conv, &mut events);
288 }
289 Some(events)
290}
291
292fn extract_text(content: Option<&Value>) -> Option<String> {
296 match content? {
297 Value::String(s) => Some(s.clone()),
298 Value::Array(blocks) => {
299 let parts: Vec<String> = blocks.iter().filter_map(extract_block).collect();
300 if parts.is_empty() {
301 None
302 } else {
303 Some(parts.join("\n"))
304 }
305 }
306 _ => None,
307 }
308}
309
310fn extract_block(block: &Value) -> Option<String> {
318 let map = block.as_object()?;
319 match map.get("type")?.as_str()? {
320 "text" | "input_text" | "output_text" => {
323 map.get("text").and_then(|v| v.as_str()).map(str::to_string)
324 }
325 "tool_use" => {
326 let name = map
327 .get("name")
328 .and_then(|v| v.as_str())
329 .unwrap_or("unknown");
330 let input = map.get("input").cloned().unwrap_or(Value::Null);
331 let input_str = truncate(&serde_json::to_string(&input).unwrap_or_default(), 500);
332 Some(format!("[tool_use: {name}] {input_str}"))
333 }
334 "tool_result" => {
335 let content_val = map.get("content").cloned().unwrap_or(Value::Null);
336 let content_str = match &content_val {
337 Value::String(s) => s.clone(),
338 other => serde_json::to_string(other).unwrap_or_default(),
339 };
340 Some(format!("[tool_result] {}", truncate(&content_str, 500)))
341 }
342 _ => None,
343 }
344}
345
346fn truncate(s: &str, max_chars: usize) -> String {
348 if s.chars().count() <= max_chars {
349 s.to_string()
350 } else {
351 let mut out: String = s.chars().take(max_chars).collect();
352 out.push('…');
353 out
354 }
355}
356
357struct ConvContext<'a> {
360 mapping: &'a Map<String, Value>,
361 current_path: &'a HashSet<String>,
362 session_id: &'a str,
363 conv_created_at_micros: i64,
366 slug: Option<&'a str>,
367}
368
369fn parse_conversation(conv: &Value, out: &mut Vec<ParsedEvent>) {
376 let Some(conv_obj) = conv.as_object() else {
377 return;
378 };
379 let Some(session_id) = conv_obj
380 .get("id")
381 .and_then(|v| v.as_str())
382 .filter(|s| !s.is_empty())
383 else {
384 return;
385 };
386 let Some(mapping) = conv_obj.get("mapping").and_then(|v| v.as_object()) else {
387 return;
388 };
389
390 let slug = conv_obj
391 .get("title")
392 .and_then(|v| v.as_str())
393 .filter(|s| !s.is_empty())
394 .map(str::to_string);
395
396 let conv_created_at_micros = conv_obj
397 .get("create_time")
398 .and_then(|v| v.as_f64())
399 .map(|secs| (secs * 1_000_000.0) as i64)
400 .unwrap_or(0);
401
402 let mut current_path: HashSet<String> = HashSet::new();
407 if let Some(current_node) = conv_obj.get("current_node").and_then(|v| v.as_str()) {
408 let mut cursor = Some(current_node.to_string());
409 while let Some(node_id) = cursor {
410 if !current_path.insert(node_id.clone()) {
411 break; }
413 cursor = mapping
414 .get(&node_id)
415 .and_then(|n| n.as_object())
416 .and_then(|n| n.get("parent"))
417 .and_then(|v| v.as_str())
418 .map(str::to_string);
419 }
420 }
421
422 let root_id = mapping.iter().find_map(|(id, node)| {
423 let node_obj = node.as_object()?;
424 let parent_is_null = node_obj.get("parent").map(|v| v.is_null()).unwrap_or(true);
425 parent_is_null.then(|| id.clone())
426 });
427 let Some(root_id) = root_id else {
428 return;
429 };
430
431 let ctx = ConvContext {
432 mapping,
433 current_path: ¤t_path,
434 session_id,
435 conv_created_at_micros,
436 slug: slug.as_deref(),
437 };
438
439 let mut stack: Vec<String> = vec![root_id];
444 let mut visited: HashSet<String> = HashSet::new();
445
446 while let Some(node_id) = stack.pop() {
447 if !visited.insert(node_id.clone()) {
448 continue; }
450 let Some(node) = mapping.get(&node_id).and_then(|n| n.as_object()) else {
451 continue;
452 };
453
454 if let Some(message) = node.get("message").filter(|m| !m.is_null()) {
455 if let Some(message_obj) = message.as_object() {
456 if let Some(ev) = build_chatgpt_event(&node_id, node, message_obj, &ctx) {
457 out.push(ev);
458 }
459 }
460 }
461
462 if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
463 for child in children.iter().rev() {
466 if let Some(child_id) = child.as_str() {
467 stack.push(child_id.to_string());
468 }
469 }
470 }
471 }
472}
473
474fn build_chatgpt_event(
480 node_id: &str,
481 node: &Map<String, Value>,
482 message: &Map<String, Value>,
483 ctx: &ConvContext,
484) -> Option<ParsedEvent> {
485 let uuid = message
486 .get("id")
487 .and_then(|v| v.as_str())
488 .filter(|s| !s.is_empty())?
489 .to_string();
490
491 let role = message
492 .get("author")
493 .and_then(|a| a.as_object())
494 .and_then(|a| a.get("role"))
495 .and_then(|v| v.as_str())
496 .map(str::to_string);
497
498 let content = message.get("content").and_then(|c| c.as_object());
499 let content_type = content
500 .and_then(|c| c.get("content_type"))
501 .and_then(|v| v.as_str())
502 .unwrap_or("unknown")
503 .to_string();
504
505 let text = extract_chatgpt_text(&content_type, content)?;
506 if text.trim().is_empty() {
507 return None;
508 }
509
510 let created_at_micros = message
511 .get("create_time")
512 .and_then(|v| v.as_f64())
513 .map(|secs| (secs * 1_000_000.0) as i64)
514 .unwrap_or(ctx.conv_created_at_micros);
515
516 let parent_uuid = node
523 .get("parent")
524 .and_then(|v| v.as_str())
525 .filter(|pid| {
526 ctx.mapping
527 .get(*pid)
528 .and_then(|p| p.as_object())
529 .and_then(|p| p.get("message"))
530 .map(|m| !m.is_null())
531 .unwrap_or(false)
532 })
533 .map(str::to_string);
534
535 let is_sidechain = !ctx.current_path.contains(node_id);
536
537 let raw_json = serde_json::to_string(node).unwrap_or_default();
538 let raw = secret_gate::mask_secrets(&raw_json).into_owned();
539 let text = secret_gate::mask_secrets(&text).into_owned();
540
541 Some(ParsedEvent {
542 uuid,
543 session_id: ctx.session_id.to_string(),
544 parent_uuid,
545 is_sidechain,
546 role,
547 msg_type: content_type,
548 text: Some(text),
549 raw,
550 created_at_micros,
551 cwd: None,
552 git_branch: None,
553 slug: ctx.slug.map(str::to_string),
554 })
555}
556
557fn extract_chatgpt_text(
565 content_type: &str,
566 content: Option<&Map<String, Value>>,
567) -> Option<String> {
568 let content = content?;
569
570 if content_type == "text" {
571 let parts = content.get("parts")?.as_array()?;
572 let joined: Vec<String> = parts
573 .iter()
574 .filter_map(|p| p.as_str().map(str::to_string))
575 .collect();
576 return if joined.is_empty() {
577 None
578 } else {
579 Some(joined.join("\n"))
580 };
581 }
582
583 if let Some(text) = content.get("text").and_then(|v| v.as_str()) {
584 return Some(text.to_string());
585 }
586
587 let parts = content.get("parts")?.as_array()?;
588 let joined: Vec<String> = parts
589 .iter()
590 .filter_map(|p| p.as_str().map(str::to_string))
591 .collect();
592 if joined.is_empty() {
593 None
594 } else {
595 Some(joined.join("\n"))
596 }
597}
598
599#[cfg(test)]
602mod tests {
603 use super::*;
604
605 fn make_line(uuid: &str, session_id: &str, type_: &str, extra: &str) -> String {
607 format!(
608 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"{type_}","timestamp":"2026-06-29T10:00:00Z"{extra}}}"#,
609 )
610 }
611
612 #[test]
613 fn test_blank_line_returns_none() {
614 assert!(parse_cc_line("").is_none());
615 assert!(parse_cc_line(" ").is_none());
616 }
617
618 #[test]
619 fn test_no_uuid_returns_none() {
620 let line = r#"{"type":"pr-link","sessionId":"sess-1","url":"https://github.com/foo"}"#;
622 assert!(parse_cc_line(line).is_none());
623 }
624
625 #[test]
626 fn test_no_session_id_returns_none() {
627 let line = r#"{"uuid":"aaaa-bbbb","type":"user"}"#;
628 assert!(parse_cc_line(line).is_none());
629 }
630
631 #[test]
632 fn test_user_text_line() {
633 let line = make_line(
634 "aaaa-bbbb",
635 "sess-1111",
636 "user",
637 r#","message":{"role":"user","content":"Hello world"},"cwd":"/proj","gitBranch":"main","slug":"my-proj""#,
638 );
639 let ev = parse_cc_line(&line).expect("should parse");
640 assert_eq!(ev.uuid, "aaaa-bbbb");
641 assert_eq!(ev.session_id, "sess-1111");
642 assert_eq!(ev.role.as_deref(), Some("user"));
643 assert_eq!(ev.msg_type, "user");
644 assert_eq!(ev.text.as_deref(), Some("Hello world"));
645 assert_eq!(ev.cwd.as_deref(), Some("/proj"));
646 assert_eq!(ev.git_branch.as_deref(), Some("main"));
647 assert_eq!(ev.slug.as_deref(), Some("my-proj"));
648 assert!(ev.created_at_micros > 0);
649 assert!(!ev.is_sidechain);
650 }
651
652 #[test]
653 fn test_assistant_with_text_and_tool_use_blocks() {
654 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"}}]}}"#
655 .to_string();
656 let ev = parse_cc_line(&line).expect("should parse");
657 assert_eq!(ev.role.as_deref(), Some("assistant"));
658 let text = ev.text.expect("text should be present");
659 assert!(text.contains("I'll run a search."), "text: {text}");
660 assert!(text.contains("[tool_use: bash]"), "text: {text}");
661 assert!(text.contains("command"), "text: {text}");
662 }
663
664 #[test]
665 fn test_tool_result_block() {
666 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"}]}}"#
667 .to_string();
668 let ev = parse_cc_line(&line).expect("should parse");
669 let text = ev.text.expect("text should be present");
670 assert!(text.contains("[tool_result]"), "text: {text}");
671 assert!(text.contains("file1.rs"), "text: {text}");
672 }
673
674 #[test]
675 fn test_attachment_line_no_message() {
676 let line = r#"{"uuid":"gggg-hhhh","sessionId":"sess-1111","type":"attachment","timestamp":"2026-06-29T10:02:00Z","filename":"file.txt"}"#
678 .to_string();
679 let ev = parse_cc_line(&line).expect("should parse");
680 assert_eq!(ev.msg_type, "attachment");
681 assert!(ev.role.is_none());
682 assert!(ev.text.is_none());
683 }
684
685 #[test]
686 fn test_secret_masking_in_text_and_raw() {
687 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
688 let line = format!(
689 r#"{{"uuid":"iiii-jjjj","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:03:00Z","message":{{"role":"user","content":"my key is {secret}"}}}}"#
690 );
691 let ev = parse_cc_line(&line).expect("should parse");
692
693 let text = ev.text.expect("text should be present");
694 assert!(
695 !text.contains(secret),
696 "secret must not appear in text: {text}"
697 );
698 assert!(
699 text.contains("***MASKED***"),
700 "MASKED marker must appear in text: {text}"
701 );
702
703 assert!(
704 !ev.raw.contains(secret),
705 "secret must not appear in raw: {}",
706 ev.raw
707 );
708 assert!(
709 ev.raw.contains("***MASKED***"),
710 "MASKED marker must appear in raw: {}",
711 ev.raw
712 );
713 }
714
715 #[test]
716 fn test_github_pat_masked() {
717 let secret = "github_pat_ABCDE12345fghij67890KLMNO";
718 let line = format!(
719 r#"{{"uuid":"kkkk-llll","sessionId":"sess-2","type":"user","timestamp":"2026-06-29T10:04:00Z","message":{{"role":"user","content":"token={secret}"}}}}"#
720 );
721 let ev = parse_cc_line(&line).unwrap();
722 assert!(!ev.raw.contains(secret));
723 assert!(ev.raw.contains("***MASKED***"));
724 }
725
726 #[test]
727 fn test_timestamp_to_micros() {
728 let line = make_line(
729 "ts-test",
730 "sess-ts",
731 "system",
732 r#","timestamp":"2026-06-29T17:56:01.123Z""#,
733 );
734 let ev = parse_cc_line(&line).unwrap();
735 assert!(ev.created_at_micros > 0, "created_at_micros should be > 0");
737 }
738
739 #[test]
740 fn test_sidechain_flag() {
741 let line = make_line("side-uuid", "sess-side", "user", r#","isSidechain":true"#);
742 let ev = parse_cc_line(&line).unwrap();
743 assert!(ev.is_sidechain);
744 }
745
746 const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
749
750 fn codex_user_msg(text: &str) -> String {
752 format!(
753 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#
754 )
755 }
756
757 fn codex_asst_msg(text: &str) -> String {
759 format!(
760 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"{text}"}}]}}}}"#
761 )
762 }
763
764 fn codex_meta(cwd: &str, branch: &str) -> String {
766 format!(
767 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"}}}}}}"#
768 )
769 }
770
771 fn codex_tool_use_msg() -> String {
773 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()
774 }
775
776 fn codex_event_msg() -> String {
778 r#"{"type":"event_msg","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"user_message","content":"duplicate"}}"#.to_string()
779 }
780
781 #[test]
782 fn test_codex_blank_returns_none() {
783 assert!(parse_codex_line("", CDX_SID, 0).is_none());
784 assert!(parse_codex_line(" ", CDX_SID, 0).is_none());
785 }
786
787 #[test]
788 fn test_codex_event_msg_skipped() {
789 let line = codex_event_msg();
790 assert!(
791 parse_codex_line(&line, CDX_SID, 42).is_none(),
792 "event_msg must be skipped"
793 );
794 }
795
796 #[test]
797 fn test_codex_unknown_type_skipped() {
798 let line = r#"{"type":"some_other_event","timestamp":"2026-06-30T09:00:00Z","payload":{}}"#;
799 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
800 }
801
802 #[test]
803 fn test_codex_session_meta_produces_event() {
804 let line = codex_meta("/workspace/proj", "main");
805 let ev = parse_codex_line(&line, CDX_SID, 0).expect("session_meta should parse");
806 assert_eq!(ev.session_id, CDX_SID);
807 assert_eq!(ev.msg_type, "session_meta");
808 assert_eq!(ev.cwd.as_deref(), Some("/workspace/proj"));
809 assert_eq!(ev.git_branch.as_deref(), Some("main"));
810 assert!(ev.role.is_none());
811 assert!(ev.text.is_none());
812 assert_eq!(ev.uuid, format!("{CDX_SID}:0"));
814 assert!(ev.created_at_micros > 0);
815 }
816
817 #[test]
818 fn test_codex_user_message_input_text_block() {
819 let line = codex_user_msg("Hello Codex");
821 let ev = parse_codex_line(&line, CDX_SID, 128).expect("user message should parse");
822 assert_eq!(ev.session_id, CDX_SID);
823 assert_eq!(ev.msg_type, "response_item");
824 assert_eq!(ev.role.as_deref(), Some("user"));
825 let text = ev.text.expect("text must be non-NULL for input_text block");
827 assert_eq!(text, "Hello Codex");
828 assert_eq!(ev.uuid, format!("{CDX_SID}:128"));
829 }
830
831 #[test]
832 fn test_codex_assistant_message_output_text_block() {
833 let line = codex_asst_msg("Hello from assistant");
835 let ev = parse_codex_line(&line, CDX_SID, 256).expect("assistant message should parse");
836 assert_eq!(ev.role.as_deref(), Some("assistant"));
837 let text = ev
839 .text
840 .expect("text must be non-NULL for output_text block");
841 assert_eq!(text, "Hello from assistant");
842 assert_eq!(ev.uuid, format!("{CDX_SID}:256"));
843 }
844
845 #[test]
846 fn test_codex_tool_use_block_extracted() {
847 let line = codex_tool_use_msg();
848 let ev = parse_codex_line(&line, CDX_SID, 512).expect("tool_use message should parse");
849 assert_eq!(ev.role.as_deref(), Some("assistant"));
850 let text = ev.text.expect("text must be present");
851 assert!(text.contains("[tool_use: bash]"), "text: {text}");
852 assert!(text.contains("cargo test"), "text: {text}");
853 }
854
855 #[test]
856 fn test_codex_text_truncated_at_500_chars() {
857 let long_text = "x".repeat(600);
859 let line = codex_user_msg(&long_text);
860 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
861 let text = ev.text.expect("text must be present");
862 assert!(
864 text.chars().count() <= 501,
865 "text must be truncated: len={}",
866 text.chars().count()
867 );
868 assert!(text.ends_with('…'), "truncated text must end with ellipsis");
869 }
870
871 #[test]
872 fn test_codex_secret_masked_in_text_and_raw() {
873 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
875 let line = codex_user_msg(secret);
876 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
877 let text = ev.text.expect("text present");
878 assert!(!text.contains(secret), "secret must not appear in text");
879 assert!(
880 text.contains("***MASKED***"),
881 "MASKED marker must appear in text"
882 );
883 assert!(!ev.raw.contains(secret), "secret must not appear in raw");
884 assert!(
885 ev.raw.contains("***MASKED***"),
886 "MASKED marker must appear in raw"
887 );
888 }
889
890 #[test]
891 fn test_codex_synthetic_uuid_stable_across_calls() {
892 let line = codex_user_msg("consistency");
895 let ev1 = parse_codex_line(&line, CDX_SID, 999).unwrap();
896 let ev2 = parse_codex_line(&line, CDX_SID, 999).unwrap();
897 assert_eq!(ev1.uuid, ev2.uuid);
898 assert_eq!(ev1.uuid, format!("{CDX_SID}:999"));
899 }
900
901 #[test]
902 fn test_codex_response_item_non_message_payload_skipped() {
903 let line = r#"{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"tool_call","name":"some_tool"}}"#;
905 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
906 }
907
908 #[test]
909 fn test_cc_text_block_still_works() {
910 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"}]}}"#;
913 let ev = parse_cc_line(line).expect("CC text block must parse");
914 assert_eq!(ev.text.as_deref(), Some("CC still works"));
915 }
916
917 #[test]
924 fn test_chatgpt_export_minimal_valid_export() {
925 let export = serde_json::json!([{
926 "id": "conv-min",
927 "title": "Minimal Export",
928 "current_node": "msg-assistant",
929 "create_time": 1_700_000_000.0,
930 "mapping": {
931 "root": {
932 "id": "root",
933 "message": null,
934 "parent": null,
935 "children": ["msg-user"]
936 },
937 "msg-user": {
938 "id": "msg-user",
939 "parent": "root",
940 "children": ["msg-assistant"],
941 "message": {
942 "id": "msg-user",
943 "author": {"role": "user"},
944 "content": {"content_type": "text", "parts": ["Hello"]}
945 }
946 },
947 "msg-assistant": {
948 "id": "msg-assistant",
949 "parent": "msg-user",
950 "children": [],
951 "message": {
952 "id": "msg-assistant",
953 "author": {"role": "assistant"},
954 "content": {"content_type": "text", "parts": ["Hi there"]}
955 }
956 }
957 }
958 }]);
959 let content = serde_json::to_string(&export).unwrap();
960
961 let events = parse_chatgpt_export(&content).expect("valid export must parse");
962 assert_eq!(events.len(), 2, "root has no message, 2 nodes carry one");
963
964 assert_eq!(events[0].uuid, "msg-user");
965 assert_eq!(events[0].session_id, "conv-min");
966 assert_eq!(events[0].role.as_deref(), Some("user"));
967 assert_eq!(events[0].text.as_deref(), Some("Hello"));
968 assert_eq!(events[0].parent_uuid, None, "root carries no message");
969 assert!(!events[0].is_sidechain);
970 assert_eq!(events[0].slug.as_deref(), Some("Minimal Export"));
971
972 assert_eq!(events[1].uuid, "msg-assistant");
973 assert_eq!(events[1].role.as_deref(), Some("assistant"));
974 assert_eq!(events[1].text.as_deref(), Some("Hi there"));
975 assert_eq!(events[1].parent_uuid.as_deref(), Some("msg-user"));
976 assert!(!events[1].is_sidechain);
977 }
978
979 #[test]
980 fn test_chatgpt_export_multi_branch_tree_dfs_order_and_sidechain() {
981 let export = serde_json::json!([{
983 "id": "conv-branch",
984 "title": "Branching Conversation",
985 "current_node": "msg-main",
986 "mapping": {
987 "root": {
988 "id": "root",
989 "message": null,
990 "parent": null,
991 "children": ["msg-user"]
992 },
993 "msg-user": {
994 "id": "msg-user",
995 "parent": "root",
996 "children": ["msg-main", "msg-alt"],
997 "message": {
998 "id": "msg-user",
999 "author": {"role": "user"},
1000 "content": {"content_type": "text", "parts": ["Question"]}
1001 }
1002 },
1003 "msg-main": {
1004 "id": "msg-main",
1005 "parent": "msg-user",
1006 "children": [],
1007 "message": {
1008 "id": "msg-main",
1009 "author": {"role": "assistant"},
1010 "content": {"content_type": "text", "parts": ["Main answer"]}
1011 }
1012 },
1013 "msg-alt": {
1014 "id": "msg-alt",
1015 "parent": "msg-user",
1016 "children": [],
1017 "message": {
1018 "id": "msg-alt",
1019 "author": {"role": "assistant"},
1020 "content": {"content_type": "text", "parts": ["Alternate answer"]}
1021 }
1022 }
1023 }
1024 }]);
1025 let content = serde_json::to_string(&export).unwrap();
1026
1027 let events = parse_chatgpt_export(&content).expect("branch export must parse");
1028 assert_eq!(events.len(), 3);
1029
1030 let uuids: Vec<&str> = events.iter().map(|e| e.uuid.as_str()).collect();
1033 assert_eq!(uuids, vec!["msg-user", "msg-main", "msg-alt"]);
1034
1035 let by_uuid = |id: &str| events.iter().find(|e| e.uuid == id).unwrap();
1036 assert!(!by_uuid("msg-user").is_sidechain);
1037 assert!(
1038 !by_uuid("msg-main").is_sidechain,
1039 "current_node's own path must not be flagged"
1040 );
1041 assert!(
1042 by_uuid("msg-alt").is_sidechain,
1043 "branch off current_node path must be flagged sidechain"
1044 );
1045 assert_eq!(by_uuid("msg-alt").text.as_deref(), Some("Alternate answer"));
1046 }
1047
1048 #[test]
1049 fn test_chatgpt_export_malformed_inputs() {
1050 assert!(parse_chatgpt_export("not json").is_none());
1052
1053 assert!(parse_chatgpt_export(r#"{"oops": "not an array"}"#).is_none());
1055
1056 let export = serde_json::json!([
1060 {"id": "no-mapping"},
1061 {"mapping": {}},
1062 "not-an-object",
1063 {
1064 "id": "conv-good",
1065 "current_node": "msg-good",
1066 "mapping": {
1067 "root": {"id": "root", "message": null, "parent": null, "children": ["msg-good"]},
1068 "msg-good": {
1069 "id": "msg-good",
1070 "parent": "root",
1071 "children": [],
1072 "message": {
1073 "id": "msg-good",
1074 "author": {"role": "user"},
1075 "content": {"content_type": "text", "parts": ["Still works"]}
1076 }
1077 }
1078 }
1079 }
1080 ]);
1081 let content = serde_json::to_string(&export).unwrap();
1082
1083 let events = parse_chatgpt_export(&content)
1084 .expect("array with some malformed conversations still parses");
1085 assert_eq!(
1086 events.len(),
1087 1,
1088 "malformed conversations skipped, valid one still yields its event"
1089 );
1090 assert_eq!(events[0].uuid, "msg-good");
1091 assert_eq!(events[0].session_id, "conv-good");
1092 }
1093}