1use chrono::DateTime;
7use khive_runtime::secret_gate;
8use serde_json::Value;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct ParsedEvent {
13 pub uuid: String,
19 pub session_id: String,
21 pub parent_uuid: Option<String>,
23 pub is_sidechain: bool,
25 pub role: Option<String>,
27 pub msg_type: String,
29 pub text: Option<String>,
31 pub raw: String,
33 pub created_at_micros: i64,
35 pub cwd: Option<String>,
37 pub git_branch: Option<String>,
39 pub slug: Option<String>,
41}
42
43pub fn parse_cc_line(line: &str) -> Option<ParsedEvent> {
53 let trimmed = line.trim();
54 if trimmed.is_empty() {
55 return None;
56 }
57
58 let obj: Value = serde_json::from_str(trimmed).ok()?;
59 let map = obj.as_object()?;
60
61 let uuid = map.get("uuid")?.as_str()?.to_string();
63 if uuid.is_empty() {
64 return None;
65 }
66 let session_id = map.get("sessionId")?.as_str()?.to_string();
67 if session_id.is_empty() {
68 return None;
69 }
70
71 let parent_uuid = map
72 .get("parentUuid")
73 .and_then(|v| v.as_str())
74 .filter(|s| !s.is_empty())
75 .map(str::to_string);
76
77 let is_sidechain = map
78 .get("isSidechain")
79 .and_then(|v| v.as_bool())
80 .unwrap_or(false);
81
82 let msg_type = map
83 .get("type")
84 .and_then(|v| v.as_str())
85 .unwrap_or("unknown")
86 .to_string();
87
88 let cwd = map.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
89
90 let git_branch = map
91 .get("gitBranch")
92 .and_then(|v| v.as_str())
93 .map(str::to_string);
94
95 let slug = map.get("slug").and_then(|v| v.as_str()).map(str::to_string);
96
97 let created_at_micros = map
98 .get("timestamp")
99 .and_then(|v| v.as_str())
100 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
101 .map(|dt| dt.timestamp_micros())
102 .unwrap_or(0);
103
104 let (role, text) = match map.get("message").and_then(|m| m.as_object()) {
106 None => (None, None),
107 Some(msg) => {
108 let role = msg.get("role").and_then(|v| v.as_str()).map(str::to_string);
109 let text = extract_text(msg.get("content"));
110 (role, text)
111 }
112 };
113
114 let raw = secret_gate::mask_secrets(trimmed).into_owned();
118 let text = text.map(|t| secret_gate::mask_secrets(&t).into_owned());
119
120 Some(ParsedEvent {
121 uuid,
122 session_id,
123 parent_uuid,
124 is_sidechain,
125 role,
126 msg_type,
127 text,
128 raw,
129 created_at_micros,
130 cwd,
131 git_branch,
132 slug,
133 })
134}
135
136pub fn parse_codex_line(line: &str, session_id: &str, abs_byte_offset: u64) -> Option<ParsedEvent> {
153 let trimmed = line.trim();
154 if trimmed.is_empty() {
155 return None;
156 }
157
158 let obj: Value = serde_json::from_str(trimmed).ok()?;
159 let map = obj.as_object()?;
160
161 let line_type = map.get("type")?.as_str()?;
162
163 if line_type == "event_msg" {
165 return None;
166 }
167
168 let created_at_micros = map
169 .get("timestamp")
170 .and_then(|v| v.as_str())
171 .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
172 .map(|dt| dt.timestamp_micros())
173 .unwrap_or(0);
174
175 match line_type {
176 "session_meta" => {
177 let payload = map.get("payload").and_then(|v| v.as_object());
182
183 let cwd = payload
184 .and_then(|p| p.get("cwd"))
185 .and_then(|v| v.as_str())
186 .map(str::to_string);
187
188 let git_branch = payload
189 .and_then(|p| p.get("git"))
190 .and_then(|g| g.as_object())
191 .and_then(|g| g.get("branch"))
192 .and_then(|v| v.as_str())
193 .map(str::to_string);
194
195 let uuid = format!("{session_id}:{abs_byte_offset}");
196 let raw = secret_gate::mask_secrets(trimmed).into_owned();
197
198 Some(ParsedEvent {
199 uuid,
200 session_id: session_id.to_string(),
201 parent_uuid: None,
202 is_sidechain: false,
203 role: None,
204 msg_type: "session_meta".to_string(),
205 text: None,
206 raw,
207 created_at_micros,
208 cwd,
209 git_branch,
210 slug: None,
211 })
212 }
213 "response_item" => {
214 let payload = map.get("payload").and_then(|v| v.as_object())?;
215
216 if payload.get("type").and_then(|v| v.as_str()) != Some("message") {
218 return None;
219 }
220
221 let role = payload
222 .get("role")
223 .and_then(|v| v.as_str())
224 .map(str::to_string);
225
226 let text = extract_text(payload.get("content"));
227 let text = text.map(|t| {
228 let masked = secret_gate::mask_secrets(&t).into_owned();
229 truncate(&masked, 500)
230 });
231
232 let uuid = format!("{session_id}:{abs_byte_offset}");
233 let raw = secret_gate::mask_secrets(trimmed).into_owned();
234
235 Some(ParsedEvent {
236 uuid,
237 session_id: session_id.to_string(),
238 parent_uuid: None,
239 is_sidechain: false,
240 role,
241 msg_type: "response_item".to_string(),
242 text,
243 raw,
244 created_at_micros,
245 cwd: None,
246 git_branch: None,
247 slug: None,
248 })
249 }
250 _ => None,
252 }
253}
254
255fn extract_text(content: Option<&Value>) -> Option<String> {
259 match content? {
260 Value::String(s) => Some(s.clone()),
261 Value::Array(blocks) => {
262 let parts: Vec<String> = blocks.iter().filter_map(extract_block).collect();
263 if parts.is_empty() {
264 None
265 } else {
266 Some(parts.join("\n"))
267 }
268 }
269 _ => None,
270 }
271}
272
273fn extract_block(block: &Value) -> Option<String> {
281 let map = block.as_object()?;
282 match map.get("type")?.as_str()? {
283 "text" | "input_text" | "output_text" => {
286 map.get("text").and_then(|v| v.as_str()).map(str::to_string)
287 }
288 "tool_use" => {
289 let name = map
290 .get("name")
291 .and_then(|v| v.as_str())
292 .unwrap_or("unknown");
293 let input = map.get("input").cloned().unwrap_or(Value::Null);
294 let input_str = truncate(&serde_json::to_string(&input).unwrap_or_default(), 500);
295 Some(format!("[tool_use: {name}] {input_str}"))
296 }
297 "tool_result" => {
298 let content_val = map.get("content").cloned().unwrap_or(Value::Null);
299 let content_str = match &content_val {
300 Value::String(s) => s.clone(),
301 other => serde_json::to_string(other).unwrap_or_default(),
302 };
303 Some(format!("[tool_result] {}", truncate(&content_str, 500)))
304 }
305 _ => None,
306 }
307}
308
309fn truncate(s: &str, max_chars: usize) -> String {
311 if s.chars().count() <= max_chars {
312 s.to_string()
313 } else {
314 let mut out: String = s.chars().take(max_chars).collect();
315 out.push('…');
316 out
317 }
318}
319
320#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn make_line(uuid: &str, session_id: &str, type_: &str, extra: &str) -> String {
328 format!(
329 r#"{{"uuid":"{uuid}","sessionId":"{session_id}","type":"{type_}","timestamp":"2026-06-29T10:00:00Z"{extra}}}"#,
330 )
331 }
332
333 #[test]
334 fn test_blank_line_returns_none() {
335 assert!(parse_cc_line("").is_none());
336 assert!(parse_cc_line(" ").is_none());
337 }
338
339 #[test]
340 fn test_no_uuid_returns_none() {
341 let line = r#"{"type":"pr-link","sessionId":"sess-1","url":"https://github.com/foo"}"#;
343 assert!(parse_cc_line(line).is_none());
344 }
345
346 #[test]
347 fn test_no_session_id_returns_none() {
348 let line = r#"{"uuid":"aaaa-bbbb","type":"user"}"#;
349 assert!(parse_cc_line(line).is_none());
350 }
351
352 #[test]
353 fn test_user_text_line() {
354 let line = make_line(
355 "aaaa-bbbb",
356 "sess-1111",
357 "user",
358 r#","message":{"role":"user","content":"Hello world"},"cwd":"/proj","gitBranch":"main","slug":"my-proj""#,
359 );
360 let ev = parse_cc_line(&line).expect("should parse");
361 assert_eq!(ev.uuid, "aaaa-bbbb");
362 assert_eq!(ev.session_id, "sess-1111");
363 assert_eq!(ev.role.as_deref(), Some("user"));
364 assert_eq!(ev.msg_type, "user");
365 assert_eq!(ev.text.as_deref(), Some("Hello world"));
366 assert_eq!(ev.cwd.as_deref(), Some("/proj"));
367 assert_eq!(ev.git_branch.as_deref(), Some("main"));
368 assert_eq!(ev.slug.as_deref(), Some("my-proj"));
369 assert!(ev.created_at_micros > 0);
370 assert!(!ev.is_sidechain);
371 }
372
373 #[test]
374 fn test_assistant_with_text_and_tool_use_blocks() {
375 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"}}]}}"#
376 .to_string();
377 let ev = parse_cc_line(&line).expect("should parse");
378 assert_eq!(ev.role.as_deref(), Some("assistant"));
379 let text = ev.text.expect("text should be present");
380 assert!(text.contains("I'll run a search."), "text: {text}");
381 assert!(text.contains("[tool_use: bash]"), "text: {text}");
382 assert!(text.contains("command"), "text: {text}");
383 }
384
385 #[test]
386 fn test_tool_result_block() {
387 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"}]}}"#
388 .to_string();
389 let ev = parse_cc_line(&line).expect("should parse");
390 let text = ev.text.expect("text should be present");
391 assert!(text.contains("[tool_result]"), "text: {text}");
392 assert!(text.contains("file1.rs"), "text: {text}");
393 }
394
395 #[test]
396 fn test_attachment_line_no_message() {
397 let line = r#"{"uuid":"gggg-hhhh","sessionId":"sess-1111","type":"attachment","timestamp":"2026-06-29T10:02:00Z","filename":"file.txt"}"#
399 .to_string();
400 let ev = parse_cc_line(&line).expect("should parse");
401 assert_eq!(ev.msg_type, "attachment");
402 assert!(ev.role.is_none());
403 assert!(ev.text.is_none());
404 }
405
406 #[test]
407 fn test_secret_masking_in_text_and_raw() {
408 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
409 let line = format!(
410 r#"{{"uuid":"iiii-jjjj","sessionId":"sess-1111","type":"user","timestamp":"2026-06-29T10:03:00Z","message":{{"role":"user","content":"my key is {secret}"}}}}"#
411 );
412 let ev = parse_cc_line(&line).expect("should parse");
413
414 let text = ev.text.expect("text should be present");
415 assert!(
416 !text.contains(secret),
417 "secret must not appear in text: {text}"
418 );
419 assert!(
420 text.contains("***MASKED***"),
421 "MASKED marker must appear in text: {text}"
422 );
423
424 assert!(
425 !ev.raw.contains(secret),
426 "secret must not appear in raw: {}",
427 ev.raw
428 );
429 assert!(
430 ev.raw.contains("***MASKED***"),
431 "MASKED marker must appear in raw: {}",
432 ev.raw
433 );
434 }
435
436 #[test]
437 fn test_github_pat_masked() {
438 let secret = "github_pat_ABCDE12345fghij67890KLMNO";
439 let line = format!(
440 r#"{{"uuid":"kkkk-llll","sessionId":"sess-2","type":"user","timestamp":"2026-06-29T10:04:00Z","message":{{"role":"user","content":"token={secret}"}}}}"#
441 );
442 let ev = parse_cc_line(&line).unwrap();
443 assert!(!ev.raw.contains(secret));
444 assert!(ev.raw.contains("***MASKED***"));
445 }
446
447 #[test]
448 fn test_timestamp_to_micros() {
449 let line = make_line(
450 "ts-test",
451 "sess-ts",
452 "system",
453 r#","timestamp":"2026-06-29T17:56:01.123Z""#,
454 );
455 let ev = parse_cc_line(&line).unwrap();
456 assert!(ev.created_at_micros > 0, "created_at_micros should be > 0");
458 }
459
460 #[test]
461 fn test_sidechain_flag() {
462 let line = make_line("side-uuid", "sess-side", "user", r#","isSidechain":true"#);
463 let ev = parse_cc_line(&line).unwrap();
464 assert!(ev.is_sidechain);
465 }
466
467 const CDX_SID: &str = "cdx-session-0001-0001-0001-000000000001";
470
471 fn codex_user_msg(text: &str) -> String {
473 format!(
474 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#
475 )
476 }
477
478 fn codex_asst_msg(text: &str) -> String {
480 format!(
481 r#"{{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"{text}"}}]}}}}"#
482 )
483 }
484
485 fn codex_meta(cwd: &str, branch: &str) -> String {
487 format!(
488 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"}}}}}}"#
489 )
490 }
491
492 fn codex_tool_use_msg() -> String {
494 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()
495 }
496
497 fn codex_event_msg() -> String {
499 r#"{"type":"event_msg","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"user_message","content":"duplicate"}}"#.to_string()
500 }
501
502 #[test]
503 fn test_codex_blank_returns_none() {
504 assert!(parse_codex_line("", CDX_SID, 0).is_none());
505 assert!(parse_codex_line(" ", CDX_SID, 0).is_none());
506 }
507
508 #[test]
509 fn test_codex_event_msg_skipped() {
510 let line = codex_event_msg();
511 assert!(
512 parse_codex_line(&line, CDX_SID, 42).is_none(),
513 "event_msg must be skipped"
514 );
515 }
516
517 #[test]
518 fn test_codex_unknown_type_skipped() {
519 let line = r#"{"type":"some_other_event","timestamp":"2026-06-30T09:00:00Z","payload":{}}"#;
520 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
521 }
522
523 #[test]
524 fn test_codex_session_meta_produces_event() {
525 let line = codex_meta("/workspace/proj", "main");
526 let ev = parse_codex_line(&line, CDX_SID, 0).expect("session_meta should parse");
527 assert_eq!(ev.session_id, CDX_SID);
528 assert_eq!(ev.msg_type, "session_meta");
529 assert_eq!(ev.cwd.as_deref(), Some("/workspace/proj"));
530 assert_eq!(ev.git_branch.as_deref(), Some("main"));
531 assert!(ev.role.is_none());
532 assert!(ev.text.is_none());
533 assert_eq!(ev.uuid, format!("{CDX_SID}:0"));
535 assert!(ev.created_at_micros > 0);
536 }
537
538 #[test]
539 fn test_codex_user_message_input_text_block() {
540 let line = codex_user_msg("Hello Codex");
542 let ev = parse_codex_line(&line, CDX_SID, 128).expect("user message should parse");
543 assert_eq!(ev.session_id, CDX_SID);
544 assert_eq!(ev.msg_type, "response_item");
545 assert_eq!(ev.role.as_deref(), Some("user"));
546 let text = ev.text.expect("text must be non-NULL for input_text block");
548 assert_eq!(text, "Hello Codex");
549 assert_eq!(ev.uuid, format!("{CDX_SID}:128"));
550 }
551
552 #[test]
553 fn test_codex_assistant_message_output_text_block() {
554 let line = codex_asst_msg("Hello from assistant");
556 let ev = parse_codex_line(&line, CDX_SID, 256).expect("assistant message should parse");
557 assert_eq!(ev.role.as_deref(), Some("assistant"));
558 let text = ev
560 .text
561 .expect("text must be non-NULL for output_text block");
562 assert_eq!(text, "Hello from assistant");
563 assert_eq!(ev.uuid, format!("{CDX_SID}:256"));
564 }
565
566 #[test]
567 fn test_codex_tool_use_block_extracted() {
568 let line = codex_tool_use_msg();
569 let ev = parse_codex_line(&line, CDX_SID, 512).expect("tool_use message should parse");
570 assert_eq!(ev.role.as_deref(), Some("assistant"));
571 let text = ev.text.expect("text must be present");
572 assert!(text.contains("[tool_use: bash]"), "text: {text}");
573 assert!(text.contains("cargo test"), "text: {text}");
574 }
575
576 #[test]
577 fn test_codex_text_truncated_at_500_chars() {
578 let long_text = "x".repeat(600);
580 let line = codex_user_msg(&long_text);
581 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
582 let text = ev.text.expect("text must be present");
583 assert!(
585 text.chars().count() <= 501,
586 "text must be truncated: len={}",
587 text.chars().count()
588 );
589 assert!(text.ends_with('…'), "truncated text must end with ellipsis");
590 }
591
592 #[test]
593 fn test_codex_secret_masked_in_text_and_raw() {
594 let secret = "sk-ant-api03-AAABBBCCCDDDEEEFFFGGG-XXXXX";
596 let line = codex_user_msg(secret);
597 let ev = parse_codex_line(&line, CDX_SID, 0).expect("should parse");
598 let text = ev.text.expect("text present");
599 assert!(!text.contains(secret), "secret must not appear in text");
600 assert!(
601 text.contains("***MASKED***"),
602 "MASKED marker must appear in text"
603 );
604 assert!(!ev.raw.contains(secret), "secret must not appear in raw");
605 assert!(
606 ev.raw.contains("***MASKED***"),
607 "MASKED marker must appear in raw"
608 );
609 }
610
611 #[test]
612 fn test_codex_synthetic_uuid_stable_across_calls() {
613 let line = codex_user_msg("consistency");
616 let ev1 = parse_codex_line(&line, CDX_SID, 999).unwrap();
617 let ev2 = parse_codex_line(&line, CDX_SID, 999).unwrap();
618 assert_eq!(ev1.uuid, ev2.uuid);
619 assert_eq!(ev1.uuid, format!("{CDX_SID}:999"));
620 }
621
622 #[test]
623 fn test_codex_response_item_non_message_payload_skipped() {
624 let line = r#"{"type":"response_item","timestamp":"2026-06-30T09:00:00Z","payload":{"type":"tool_call","name":"some_tool"}}"#;
626 assert!(parse_codex_line(line, CDX_SID, 0).is_none());
627 }
628
629 #[test]
630 fn test_cc_text_block_still_works() {
631 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"}]}}"#;
634 let ev = parse_cc_line(line).expect("CC text block must parse");
635 assert_eq!(ev.text.as_deref(), Some("CC still works"));
636 }
637}