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.
260///
261/// Unlike `parse_cc_line`/`parse_codex_line` (one JSONL line in, one event
262/// out), a ChatGPT export is a single static JSON array of conversation
263/// objects — this function parses the whole file at once and returns every
264/// message-bearing event across every conversation it contains.
265///
266/// Returns `None` when `content` is not valid JSON or the top level is not a
267/// JSON array. The caller treats that as a per-file error so the mirror
268/// cursor does not advance: a partially-downloaded export is retried whole
269/// on the next tick, never half-consumed. A malformed *conversation* inside
270/// an otherwise-valid array is skipped individually (see `parse_conversation`)
271/// so one bad entry cannot sink the rest of the file.
272///
273/// Each conversation's `mapping` forms a tree; events are emitted in
274/// deterministic DFS preorder from the root, following each node's
275/// `children` array order (never JSON object key order). Nodes off the
276/// `current_node` root-to-tip path are flagged `is_sidechain`, mirroring how
277/// Claude Code flags abandoned/regenerated branches.
278///
279/// The returned `raw` and `text` fields have secrets masked, exactly like
280/// `parse_cc_line`/`parse_codex_line`.
281pub 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
292/// Extract a display-friendly text string from a message `content` value.
293///
294/// Handles both the string form and the structured-block array form.
295fn 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
310/// Extract a display string from a single content block.
311///
312/// Handled block types:
313/// - `"text"` — Claude Code plain text block.
314/// - `"input_text"` / `"output_text"` — Codex user and assistant text blocks.
315/// - `"tool_use"` — tool invocation (name + input JSON, truncated to 500 chars).
316/// - `"tool_result"` — tool output (content string, truncated to 500 chars).
317fn extract_block(block: &Value) -> Option<String> {
318    let map = block.as_object()?;
319    match map.get("type")?.as_str()? {
320        // Claude Code text block and Codex user/assistant text blocks all carry
321        // their display text in a "text" field — same extraction logic.
322        "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
346/// Truncate a string to at most `max_chars` characters, appending `…` if truncated.
347fn 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
357/// Context threaded through node visitation for one conversation — the
358/// pieces that don't change as the DFS walks the mapping tree.
359struct ConvContext<'a> {
360    mapping: &'a Map<String, Value>,
361    current_path: &'a HashSet<String>,
362    session_id: &'a str,
363    /// Conversation-level `create_time` in micros (0 if absent) — the
364    /// fallback used when a message's own `create_time` is null/absent.
365    conv_created_at_micros: i64,
366    slug: Option<&'a str>,
367}
368
369/// Parse one ChatGPT export conversation object, appending its
370/// message-bearing nodes (deterministic DFS preorder from the mapping root)
371/// to `out`.
372///
373/// Skips the whole conversation on a missing/empty `id` or missing `mapping`
374/// so one malformed entry cannot sink the rest of the file.
375fn 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    // ── current-path set: walk current_node -> parent -> ... -> root ────────
403    //
404    // Off-path nodes (abandoned/regenerated branches) are flagged
405    // is_sidechain, mirroring how Claude Code flags sidechains.
406    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; // cycle guard against malformed mapping data
412            }
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: &current_path,
434        session_id,
435        conv_created_at_micros,
436        slug: slug.as_deref(),
437    };
438
439    // ── deterministic DFS preorder from the root, following `children` order ─
440    //
441    // Explicit stack, not recursion — a long linear conversation can nest
442    // thousands of turns deep and would risk overflowing a worker-thread stack.
443    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; // cycle guard
449        }
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            // Push in reverse so the first child in the array is popped (and
464            // thus visited) first — preorder must follow children order.
465            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
474/// Build a `ParsedEvent` for a single message-bearing mapping node.
475///
476/// Returns `None` when the message carries no `id`, or when the extracted
477/// text is empty/whitespace-only (ChatGPT scaffolding nodes, e.g. system
478/// prompts with `parts: [""]`).
479fn 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    // Some(parent_node_id) only when that parent node itself carries a
517    // (non-null) message — the ChatGPT root is normally message=null, so its
518    // children correctly get parent_uuid=None. A parent that DOES carry a
519    // message but was itself skipped as an event (e.g. empty-parts
520    // scaffolding) still counts — this is provenance linkage, matching how
521    // CC parent chains can reference events that were never mirrored.
522    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
557/// Extract display text from a ChatGPT message `content` object per its
558/// `content_type`.
559///
560/// - `"text"` — join string `parts` with `"\n"` (non-string parts ignored
561///   defensively).
562/// - anything else (`"code"`, `"execution_output"`, …) — `content.text` if
563///   present, else joined string `parts`, else `None`.
564fn 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// ── unit tests ────────────────────────────────────────────────────────────────
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    // Helper: build a minimal CC event JSON string.
606    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        // pr-link style: no uuid
621        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        // uuid present, sessionId present, but no message -> role/text None
677        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        // 2026-06-29T17:56:01.123Z in micros should be a large positive number
736        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    // ── parse_codex_line tests ─────────────────────────────────────────────────
747
748    const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
749
750    /// Build a Codex user message line using the real `input_text` block shape.
751    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    /// Build a Codex assistant message line using the real `output_text` block shape.
758    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    /// Build a Codex session_meta line.
765    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    /// Build a Codex response_item with a tool_use block (no text block).
772    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    /// Build a Codex event_msg line (duplicate; must be skipped).
777    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        // Synthetic uuid: "{session_id}:{offset}".
813        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        // Regression for Finding 1: real Codex user messages use `input_text` blocks.
820        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        // text must NOT be None — this was the NULL bug.
826        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        // Regression for Finding 1: real Codex assistant messages use `output_text` blocks.
834        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        // text must NOT be None.
838        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        // input_text block with 600-char body — must be truncated.
858        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        // char count must be ≤ 501 (500 + the '…' ellipsis char).
863        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        // input_text block carrying a secret — masking must apply to both text and raw.
874        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        // The same line at the same offset must produce the same uuid regardless
893        // of how many times it is called (deterministic, no random component).
894        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        // A response_item where payload.type != "message" must be skipped.
904        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        // Regression guard: adding input_text/output_text must not break the
911        // existing CC "text" block handling.
912        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    // ── parse_chatgpt_export: direct unit tests ─────────────────────────────
918    //
919    // Unlike the end-to-end mirror_chatgpt_export_file tests in ingest.rs
920    // (which go through file I/O + SQL), these exercise the pure JSON-tree
921    // parsing seam directly with no runtime or DB setup.
922
923    #[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        // root -> user -> {main (current path), alt (regenerated/abandoned)}
982        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        // DFS preorder must follow the mapping's `children` array order, not
1031        // JSON object key order: user, then main, then alt.
1032        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        // Top level not valid JSON at all -> None (caller must not advance cursor).
1051        assert!(parse_chatgpt_export("not json").is_none());
1052
1053        // Valid JSON but not an array -> None.
1054        assert!(parse_chatgpt_export(r#"{"oops": "not an array"}"#).is_none());
1055
1056        // Valid array, but individual malformed conversations (missing
1057        // mapping, missing id, non-object entry) must be skipped
1058        // individually rather than sinking the whole file.
1059        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}