Skip to main content

khive_pack_session/mirror/
parse.rs

1//! JSONL line parsers for Claude Code and Codex CLI session transcripts.
2//!
3//! Every function here is deterministic and side-effect-free so the unit tests
4//! can run without any runtime or DB setup.
5
6use std::collections::HashSet;
7
8use chrono::DateTime;
9use khive_runtime::secret_gate;
10use serde_json::{Map, Value};
11
12/// A single parsed event, source-agnostic.
13#[derive(Debug, Clone, PartialEq)]
14pub struct ParsedEvent {
15    /// Event UUID — the primary key for idempotency.
16    ///
17    /// For Claude Code events this is the top-level `uuid` field.
18    /// For Codex events (which carry no per-message uuid) this is synthesised
19    /// as `"{session_id}:{abs_byte_offset}"`.
20    /// For ChatGPT export events this is the mapping node's `message.id`.
21    pub uuid: String,
22    /// Session UUID.
23    pub session_id: String,
24    /// Parent event UUID if present.
25    pub parent_uuid: Option<String>,
26    /// Whether this event is on a sidechain.
27    pub is_sidechain: bool,
28    /// `message.role` (CC) or `payload.role` (Codex) when present.
29    pub role: Option<String>,
30    /// Top-level `type` field.
31    pub msg_type: String,
32    /// Extracted display text, secrets masked; `None` for non-message events.
33    pub text: Option<String>,
34    /// Full original line with secrets masked.
35    pub raw: String,
36    /// `timestamp` as microseconds since the Unix epoch; 0 if absent or unparseable.
37    pub created_at_micros: i64,
38    /// `cwd` if present.
39    pub cwd: Option<String>,
40    /// `gitBranch` (CC) or `payload.git.branch` (Codex) if present.
41    pub git_branch: Option<String>,
42    /// `slug` if present (CC: project slug; ChatGPT export: conversation
43    /// title; Codex files carry no slug concept).
44    pub slug: Option<String>,
45}
46
47/// Parse one Claude Code JSONL line.
48///
49/// Returns `None` for:
50/// - blank or whitespace-only lines
51/// - lines that are not valid JSON objects
52/// - lines that lack a top-level `uuid` field
53/// - lines that lack a top-level `sessionId` field
54///
55/// The returned `raw` and `text` fields have secrets masked.
56pub 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    // Both uuid and sessionId are required for idempotency and routing.
66    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    // Extract role and text from message when present.
109    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    // Apply masking to the raw line and the extracted text, reusing the
119    // canonical write-time secret detector (khive-runtime) — never a second,
120    // weaker masker.
121    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
140/// Parse one Codex CLI JSONL line.
141///
142/// `session_id` must be derived from the filename before calling this function
143/// (e.g. from `rollout-<timestamp>-<uuid>.jsonl`).  `abs_byte_offset` is the
144/// file byte offset of the **start** of this line; it is embedded in the
145/// synthesised event UUID so that `INSERT OR IGNORE` on `session_messages.id`
146/// is idempotent across re-tails of an append-only file.
147///
148/// Returns `None` for:
149/// - blank or whitespace-only lines
150/// - lines that are not valid JSON objects
151/// - lines whose top-level `type` is `"event_msg"` — these are duplicate
152///   event-stream representations of messages and must not be double-stored
153/// - any other line type that carries no useful message content
154///
155/// The returned `raw` and `text` fields have secrets masked.
156pub 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    // event_msg lines are duplicate event-stream representations — skip them.
168    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            // session_meta carries cwd and git metadata; the session UUID in
182            // payload.id should match the filename-derived session_id, but we
183            // do NOT use it as the event id — use the synthesised offset key so
184            // the message row is unique and idempotent.
185            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            // Only ingest message items; skip tool_call, completion, etc.
221            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        // Unknown line types are silently skipped.
255        _ => None,
256    }
257}
258
259/// Parse a ChatGPT data-export `conversations.json` file: parses the whole
260/// file at once (unlike the line-at-a-time `parse_cc_line`/`parse_codex_line`)
261/// and returns every message-bearing event across every conversation, in
262/// deterministic DFS preorder per conversation.
263///
264/// Returns `None` when `content` is not valid JSON or the top level is not a
265/// JSON array — the caller treats that as a per-file error so the mirror
266/// cursor does not advance. A malformed *conversation* inside an otherwise-
267/// valid array is skipped individually, not the whole file. The returned
268/// `raw` and `text` fields have secrets masked, exactly like
269/// `parse_cc_line`/`parse_codex_line`. See
270/// `crates/khive-pack-session/docs/api/mirror-parse.md` for the DFS/sidechain
271/// algorithm detail.
272pub 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
283/// Extract a display-friendly text string from a message `content` value
284/// (string form or structured-block array form).
285fn 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
300/// Extract a display string from a single content block (`"text"` /
301/// `"input_text"` / `"output_text"` / `"tool_use"` / `"tool_result"`). See
302/// `crates/khive-pack-session/docs/api/mirror-parse.md#extract_text--extract_block-claude-code--codex-block-extraction`.
303fn extract_block(block: &Value) -> Option<String> {
304    let map = block.as_object()?;
305    match map.get("type")?.as_str()? {
306        // Claude Code text block and Codex user/assistant text blocks all carry
307        // their display text in a "text" field — same extraction logic.
308        "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
332/// Truncate a string to at most `max_chars` characters, appending `…` if truncated.
333fn 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
343/// Context threaded through one conversation's DFS walk. See
344/// `crates/khive-pack-session/docs/api/mirror-parse.md#convcontext`.
345struct 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
353/// Parse one ChatGPT export conversation object (skips the whole
354/// conversation on a missing/empty `id` or missing `mapping`), appending its
355/// message-bearing nodes to `out` in deterministic DFS preorder. See
356/// `crates/khive-pack-session/docs/api/mirror-parse.md#parse_conversation`.
357fn 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    // current-path set: walk current_node -> parent -> ... -> root; off-path
385    // nodes are flagged is_sidechain (see docs guide).
386    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; // cycle guard against malformed mapping data
392            }
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: &current_path,
414        session_id,
415        conv_created_at_micros,
416        slug: slug.as_deref(),
417    };
418
419    // Deterministic DFS preorder, explicit stack (not recursion — see docs guide).
420    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; // cycle guard
426        }
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            // Push in reverse so the first child in the array is popped (and
441            // thus visited) first — preorder must follow children order.
442            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
451/// Build a `ParsedEvent` for a single message-bearing mapping node; `None`
452/// on a missing `id` or empty/whitespace-only extracted text (ChatGPT
453/// scaffolding nodes). See
454/// `crates/khive-pack-session/docs/api/mirror-parse.md#build_chatgpt_event`.
455fn 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    // parent_uuid is Some only when the parent node itself carries a message
493    // (provenance linkage — see docs guide).
494    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
529/// Extract display text from a ChatGPT message `content` object per its
530/// `content_type`. See
531/// `crates/khive-pack-session/docs/api/mirror-parse.md#extract_chatgpt_text`.
532fn 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// ── unit tests ────────────────────────────────────────────────────────────────
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    // Helper: build a minimal CC event JSON string.
574    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        // pr-link style: no uuid
589        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        // uuid present, sessionId present, but no message -> role/text None
645        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        // 2026-06-29T17:56:01.123Z in micros should be a large positive number
704        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    // ── parse_codex_line tests ─────────────────────────────────────────────────
715
716    const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
717
718    /// Build a Codex user message line using the real `input_text` block shape.
719    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    /// Build a Codex assistant message line using the real `output_text` block shape.
726    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    /// Build a Codex session_meta line.
733    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    /// Build a Codex response_item with a tool_use block (no text block).
740    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    /// Build a Codex event_msg line (duplicate; must be skipped).
745    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        // Synthetic uuid: "{session_id}:{offset}".
781        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        // Regression: real Codex user messages use `input_text` blocks.
788        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        // text must NOT be None — this was the NULL bug.
794        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        // Regression: real Codex assistant messages use `output_text` blocks.
802        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        // text must NOT be None.
806        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        // input_text block with 600-char body — must be truncated.
826        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        // char count must be ≤ 501 (500 + the '…' ellipsis char).
831        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        // input_text block carrying a secret — masking must apply to both text and raw.
842        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        // The same line at the same offset must produce the same uuid regardless
861        // of how many times it is called (deterministic, no random component).
862        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        // A response_item where payload.type != "message" must be skipped.
872        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        // Regression guard: adding input_text/output_text must not break the
879        // existing CC "text" block handling.
880        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    // ── parse_chatgpt_export: direct unit tests ─────────────────────────────
886    //
887    // Unlike the end-to-end mirror_chatgpt_export_file tests in ingest.rs
888    // (which go through file I/O + SQL), these exercise the pure JSON-tree
889    // parsing seam directly with no runtime or DB setup.
890
891    #[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        // root -> user -> {main (current path), alt (regenerated/abandoned)}
950        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        // DFS preorder must follow the mapping's `children` array order, not
999        // JSON object key order: user, then main, then alt.
1000        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        // Top level not valid JSON at all -> None (caller must not advance cursor).
1019        assert!(parse_chatgpt_export("not json").is_none());
1020
1021        // Valid JSON but not an array -> None.
1022        assert!(parse_chatgpt_export(r#"{"oops": "not an array"}"#).is_none());
1023
1024        // Valid array, but individual malformed conversations (missing
1025        // mapping, missing id, non-object entry) must be skipped
1026        // individually rather than sinking the whole file.
1027        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}