1use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use chrono::{DateTime, SecondsFormat, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use uuid::Uuid;
19
20use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage};
21use crate::error::{Error, Result};
22use crate::harness::jsonl;
23use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ClaudeCode;
28
29impl Harness for ClaudeCode {
30 const NAME: &'static str = "claude_code";
31 type Body = Vec<Record>;
32}
33
34#[derive(Debug, Clone, PartialEq)]
39pub enum Record {
40 Summary(SummaryLine),
41 User(EntryLine),
42 Assistant(EntryLine),
43 Other(Value),
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct EntryLine {
50 #[serde(
51 rename = "parentUuid",
52 default,
53 skip_serializing_if = "Option::is_none"
54 )]
55 pub parent_uuid: Option<String>,
56 pub uuid: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub timestamp: Option<String>,
59 #[serde(rename = "sessionId", default, skip_serializing_if = "Option::is_none")]
60 pub session_id: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cwd: Option<String>,
63 #[serde(rename = "gitBranch", default, skip_serializing_if = "Option::is_none")]
64 pub git_branch: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub version: Option<String>,
67 pub message: ApiMessage,
68 #[serde(flatten)]
69 pub extra: Map<String, Value>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct SummaryLine {
75 pub summary: String,
76 #[serde(flatten)]
77 pub extra: Map<String, Value>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ApiMessage {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub role: Option<String>,
86 pub content: Value,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub model: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub stop_reason: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub usage: Option<Value>,
93 #[serde(flatten)]
94 pub extra: Map<String, Value>,
95}
96
97impl From<Value> for Record {
99 fn from(v: Value) -> Self {
100 match v.get("type").and_then(Value::as_str) {
101 Some("summary") => SummaryLine::deserialize(&v)
102 .map(Record::Summary)
103 .unwrap_or(Record::Other(v)),
104 Some("user") => EntryLine::deserialize(&v)
105 .map(Record::User)
106 .unwrap_or(Record::Other(v)),
107 Some("assistant") => EntryLine::deserialize(&v)
108 .map(Record::Assistant)
109 .unwrap_or(Record::Other(v)),
110 _ => Record::Other(v),
111 }
112 }
113}
114
115impl From<Record> for Value {
116 fn from(r: Record) -> Self {
117 fn tagged(line: impl Serialize, ty: &str) -> Value {
118 let mut v = serde_json::to_value(line).unwrap_or(Value::Null);
119 if let Value::Object(obj) = &mut v {
120 obj.insert("type".into(), Value::String(ty.into()));
121 }
122 v
123 }
124 match r {
125 Record::Summary(s) => tagged(s, "summary"),
126 Record::User(e) => tagged(e, "user"),
127 Record::Assistant(e) => tagged(e, "assistant"),
128 Record::Other(v) => v,
129 }
130 }
131}
132
133impl Serialize for Record {
134 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
135 Value::from(self.clone()).serialize(s)
136 }
137}
138
139impl<'de> Deserialize<'de> for Record {
140 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
141 Ok(Record::from(Value::deserialize(d)?))
142 }
143}
144
145impl Codec for ClaudeCode {
148 fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
149 let fallback_ts = transcript.meta.timestamp;
150 let mut messages = Vec::with_capacity(transcript.body.len());
151 let mut open_command: Option<&str> = None;
154
155 for record in &transcript.body {
156 let (role, entry) = match record {
157 Record::User(e) => (Role::User, e),
158 Record::Assistant(e) => (Role::Assistant, e),
159 Record::Other(value) => {
162 messages.extend(system_line_message(value, fallback_ts, &mut open_command));
163 continue;
164 }
165 Record::Summary(_) => continue,
167 };
168
169 let content = match envelope_body(&entry.message.content).and_then(parse_envelope) {
173 Some(envelope) => envelope.into_blocks(
174 &entry.uuid,
175 entry.parent_uuid.as_deref(),
176 &mut open_command,
177 ),
178 None => parse_blocks(&entry.message.content),
179 };
180
181 if !content.is_empty() {
184 messages.push(Message {
185 role,
186 content,
187 timestamp: entry
188 .timestamp
189 .as_deref()
190 .and_then(parse_ts)
191 .unwrap_or(fallback_ts),
192 model: entry.message.model.clone(),
193 stop_reason: entry.message.stop_reason.as_deref().map(parse_stop_reason),
194 usage: entry.message.usage.as_ref().and_then(parse_usage),
195 });
196 }
197 }
198
199 Ok(Transcript::new(transcript.meta.clone(), messages))
200 }
201
202 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
203 let meta = &transcript.meta;
204 let session_id = if meta.id.is_empty() {
205 Uuid::new_v4().to_string()
206 } else {
207 meta.id.clone()
208 };
209 let mut records = Vec::with_capacity(transcript.body.len() + 1);
210
211 let mut command_ids = std::collections::HashSet::new();
214 let called: std::collections::HashSet<&str> = transcript
219 .body
220 .iter()
221 .flat_map(|msg| &msg.content)
222 .filter_map(|block| match block {
223 Block::ToolUse { id, .. } => Some(id.as_str()),
224 _ => None,
225 })
226 .collect();
227 let mut parent_uuid: Option<String> = None;
228 for (i, msg) in transcript.body.iter().enumerate() {
229 let uuid = entry_uuid(&session_id, i);
230
231 if msg.role == Role::User
235 && let Some(record) = local_command_record(
236 msg,
237 &mut command_ids,
238 &called,
239 &uuid,
240 parent_uuid.as_deref(),
241 &session_id,
242 meta,
243 )
244 {
245 records.push(record);
246 parent_uuid = Some(uuid);
247 continue;
248 }
249
250 let api = ApiMessage {
251 role: Some(role_str(msg.role).to_string()),
252 content: serialize_blocks(&msg.content),
253 model: msg.model.clone(),
254 stop_reason: msg.stop_reason.as_ref().map(stop_reason_str),
255 usage: msg.usage.as_ref().map(serialize_usage),
256 extra: Map::new(),
257 };
258 let entry = EntryLine {
259 parent_uuid: parent_uuid.clone(),
260 uuid: uuid.clone(),
261 timestamp: Some(msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)),
262 session_id: Some(session_id.clone()),
263 cwd: meta.cwd.clone(),
264 git_branch: meta.git_branch.clone(),
265 version: meta.cli_version.clone(),
266 message: api,
267 extra: Map::new(),
268 };
269 records.push(match msg.role {
270 Role::User => Record::User(entry),
271 Role::Assistant => Record::Assistant(entry),
272 });
273 parent_uuid = Some(uuid);
274 }
275
276 if let Some(title) = meta.title.as_deref().filter(|t| !t.is_empty())
284 && let Some(leaf) = records.iter().rev().find_map(|record| match record {
285 Record::User(entry) | Record::Assistant(entry) => Some(entry.uuid.clone()),
286 _ => None,
287 })
288 {
289 records.insert(
290 0,
291 Record::Summary(SummaryLine {
292 summary: title.to_string(),
293 extra: Map::from_iter([("leafUuid".into(), Value::String(leaf))]),
294 }),
295 );
296 }
297
298 Ok(Transcript::new(meta.clone(), records))
299 }
300}
301
302impl TextCodec for ClaudeCode {
303 fn from_text(text: &str) -> Result<Transcript<Self>> {
304 let records: Vec<Record> = text
308 .lines()
309 .filter(|line| !line.trim().is_empty())
310 .filter_map(record_from_line)
311 .collect();
312 let meta = meta_from_records(&records);
313 Ok(Transcript::new(meta, records))
314 }
315
316 fn to_text(transcript: &Transcript<Self>) -> Result<String> {
317 jsonl::render(&transcript.body)
318 }
319}
320
321#[derive(Debug, Clone)]
326pub struct ClaudeStore {
327 pub root: PathBuf,
328}
329
330impl ClaudeStore {
331 pub fn new(root: impl Into<PathBuf>) -> Self {
332 Self { root: root.into() }
333 }
334
335 #[must_use]
339 pub fn default_root() -> Option<Self> {
340 std::env::var_os("CLAUDE_CONFIG_DIR")
341 .filter(|v| !v.is_empty())
342 .map(|dir| Self::new(PathBuf::from(dir).join("projects")))
343 .or_else(|| dirs_home().map(|h| Self::new(h.join(".claude").join("projects"))))
344 }
345
346 fn collect_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
347 for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
349 let path = entry.path();
350 if entry.file_type().is_ok_and(|t| t.is_dir()) {
354 let name = entry.file_name();
355 let name = name.to_string_lossy();
356 if name != "subagents" && name != "tool-results" {
358 Self::collect_jsonl(&path, out);
359 }
360 } else if path.extension().is_some_and(|e| e == "jsonl") {
361 out.push(path);
362 }
363 }
364 }
365}
366
367impl Store for ClaudeStore {
368 type H = ClaudeCode;
369 type Ref = PathBuf;
370
371 fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
372 if self.root.is_dir() {
373 let mut files = Vec::new();
374 Self::collect_jsonl(&self.root, &mut files);
375 Ok(files
376 .into_iter()
377 .filter_map(|path| {
378 fs::read_to_string(&path).ok().map(|text| {
381 let mut meta = meta_from_text(&text);
382 if meta.id.is_empty() {
383 meta.id = jsonl::file_id(&path);
384 }
385 Discovered {
386 meta,
387 reference: path,
388 }
389 })
390 })
391 .collect())
392 } else {
393 Ok(Vec::new())
395 }
396 }
397
398 fn load(&self, reference: &PathBuf) -> Result<Transcript<ClaudeCode>> {
399 let mut transcript = ClaudeCode::from_text(&fs::read_to_string(reference)?)?;
400 if transcript.meta.id.is_empty() {
401 transcript.meta.id = jsonl::file_id(reference);
402 }
403 Ok(transcript)
404 }
405
406 fn save(&self, transcript: &Transcript<ClaudeCode>) -> Result<Saved<PathBuf>> {
407 let id = transcript.meta.id.clone();
408 super::checked_id_component(ClaudeCode::NAME, &id)?;
409 let cwd = transcript.meta.cwd.as_deref().unwrap_or_default();
410 let dir = self.root.join(encode_project_dir(cwd));
411 fs::create_dir_all(&dir)?;
412 let path = dir.join(format!("{id}.jsonl"));
413 fs::write(&path, ClaudeCode::to_text(transcript)?)?;
414 Ok(Saved {
415 id,
416 reference: path,
417 })
418 }
419
420 fn delete(&self, reference: &PathBuf) -> Result<()> {
421 Ok(fs::remove_file(reference)?)
422 }
423
424 fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
425 let mut out = HashMap::with_capacity(refs.len());
426 for path in refs {
427 out.insert(path.to_string_lossy().into_owned(), file_fingerprint(path));
428 }
429 Ok(out)
430 }
431}
432
433enum Envelope {
444 Command {
445 command: String,
446 args: Option<String>,
447 },
448 Stdout(String),
449 Caveat,
452}
453
454impl Envelope {
455 fn into_blocks<'a>(
460 self,
461 uuid: &'a str,
462 parent: Option<&str>,
463 open_command: &mut Option<&'a str>,
464 ) -> Vec<Block> {
465 match self {
466 Envelope::Command { command, args } => {
467 *open_command = Some(uuid);
468 vec![Block::ToolUse {
469 id: uuid.to_string(),
470 tool: Tool::Command { command, args },
471 }]
472 }
473 Envelope::Stdout(text) => {
474 let paired = parent.filter(|p| Some(*p) == *open_command);
478 *open_command = None;
479 vec![Block::ToolResult {
480 tool_use_id: paired.unwrap_or(uuid).to_string(),
481 content: ToolOutput::Text(text),
482 is_error: false,
483 }]
484 }
485 Envelope::Caveat => Vec::new(),
486 }
487 }
488}
489
490fn envelope_body(content: &Value) -> Option<&str> {
495 match content {
496 Value::String(text) => Some(text),
497 Value::Array(blocks) => match blocks.as_slice() {
498 [block] if block.get("type").and_then(Value::as_str) == Some("text") => {
499 block.get("text")?.as_str()
500 }
501 _ => None,
502 },
503 _ => None,
504 }
505}
506
507fn parse_envelope(text: &str) -> Option<Envelope> {
512 let tags = envelope_tags(text)?;
513 let tag = |name: &str| tags.iter().find(|(t, _)| *t == name).map(|(_, body)| *body);
514
515 if let Some(name) = tag("command-name") {
516 if !tags
519 .iter()
520 .all(|(t, _)| matches!(*t, "command-name" | "command-message" | "command-args"))
521 {
522 return None;
523 }
524 let name = unescape_envelope(name.trim());
525 if name.is_empty() {
526 return None;
527 }
528 return Some(Envelope::Command {
529 command: if name.starts_with('/') {
532 name
533 } else {
534 format!("/{name}")
535 },
536 args: tag("command-args")
537 .map(str::trim)
538 .filter(|args| !args.is_empty())
539 .map(unescape_envelope),
540 });
541 }
542
543 match tags.as_slice() {
544 [("local-command-stdout", body)] => {
545 Some(Envelope::Stdout(strip_ansi(&unescape_envelope(body))))
546 }
547 [("local-command-caveat", _)] => Some(Envelope::Caveat),
548 _ => None,
550 }
551}
552
553const ENVELOPE_TAGS: &[&str] = &[
556 "command-name",
557 "command-message",
558 "command-args",
559 "local-command-stdout",
560 "local-command-caveat",
561];
562
563fn escape_envelope(text: &str) -> String {
570 shift_envelope_escapes(text, true)
571}
572
573fn unescape_envelope(text: &str) -> String {
575 shift_envelope_escapes(text, false)
576}
577
578fn shift_envelope_escapes(text: &str, add: bool) -> String {
579 let mut out = String::with_capacity(text.len());
580 let mut rest = text;
581 while let Some(pos) = rest.find('<') {
582 out.push_str(&rest[..=pos]);
583 rest = &rest[pos + 1..];
584 let backslashes = rest.len() - rest.trim_start_matches('\\').len();
585 let after = &rest[backslashes..];
586 let close_len = ENVELOPE_TAGS.iter().find_map(|tag| {
587 after
588 .strip_prefix('/')
589 .and_then(|r| r.strip_prefix(*tag))
590 .and_then(|r| r.strip_prefix('>'))
591 .map(|_| tag.len() + 2)
592 });
593 if let Some(close_len) = close_len {
594 let shifted = if add {
595 backslashes + 1
596 } else {
597 backslashes.saturating_sub(1)
598 };
599 for _ in 0..shifted {
600 out.push('\\');
601 }
602 out.push_str(&after[..close_len]);
603 rest = &after[close_len..];
604 }
605 }
608 out.push_str(rest);
609 out
610}
611
612fn envelope_tags(text: &str) -> Option<Vec<(&str, &str)>> {
616 let mut tags = Vec::new();
617 let mut rest = text.trim();
618 while !rest.is_empty() {
619 let open_end = rest.strip_prefix('<')?.find('>')? + 1;
620 let name = &rest[1..open_end];
621 if name.is_empty() || !name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') {
622 return None;
623 }
624 let body_start = open_end + 1;
625 let close = format!("</{name}>");
626 let body_len = rest[body_start..].find(&close)?;
627 tags.push((name, &rest[body_start..body_start + body_len]));
628 rest = rest[body_start + body_len + close.len()..].trim_start();
629 }
630 (!tags.is_empty()).then_some(tags)
631}
632
633fn system_line_message<'a>(
637 value: &'a Value,
638 fallback_ts: DateTime<Utc>,
639 open_command: &mut Option<&'a str>,
640) -> Option<Message> {
641 if value.get("type").and_then(Value::as_str) != Some("system") {
642 return None;
643 }
644 let uuid = value.get("uuid").and_then(Value::as_str)?;
645 let envelope = parse_envelope(value.get("content")?.as_str()?)?;
646 let parent = value.get("parentUuid").and_then(Value::as_str);
647 let content = envelope.into_blocks(uuid, parent, open_command);
648 (!content.is_empty()).then(|| Message {
649 role: Role::User,
650 content,
651 timestamp: value
652 .get("timestamp")
653 .and_then(Value::as_str)
654 .and_then(parse_ts)
655 .unwrap_or(fallback_ts),
656 model: None,
657 stop_reason: None,
658 usage: None,
659 })
660}
661
662fn local_command_record<'a>(
665 msg: &'a Message,
666 command_ids: &mut std::collections::HashSet<&'a str>,
667 called: &std::collections::HashSet<&str>,
668 uuid: &str,
669 parent_uuid: Option<&str>,
670 session_id: &str,
671 meta: &Meta,
672) -> Option<Record> {
673 let timestamp = msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true);
674 match msg.content.as_slice() {
675 [
676 Block::ToolUse {
677 id,
678 tool: Tool::Command { command, args },
679 },
680 ] => {
681 command_ids.insert(id.as_str());
682 let name = escape_envelope(command);
683 let message = escape_envelope(command.trim_start_matches('/'));
684 let body = match args {
685 Some(args) => format!(
686 "<command-name>{name}</command-name>\n\
687 <command-message>{message}</command-message>\n\
688 <command-args>{args}</command-args>",
689 args = escape_envelope(args),
690 ),
691 None => format!(
692 "<command-name>{name}</command-name>\n\
693 <command-message>{message}</command-message>"
694 ),
695 };
696 Some(Record::User(EntryLine {
697 parent_uuid: parent_uuid.map(String::from),
698 uuid: uuid.to_string(),
699 timestamp: Some(timestamp),
700 session_id: Some(session_id.to_string()),
701 cwd: meta.cwd.clone(),
702 git_branch: meta.git_branch.clone(),
703 version: meta.cli_version.clone(),
704 message: ApiMessage {
705 role: Some("user".to_string()),
706 content: Value::String(body),
707 model: None,
708 stop_reason: None,
709 usage: None,
710 extra: Map::new(),
711 },
712 extra: Map::new(),
713 }))
714 }
715 [
716 Block::ToolResult {
717 tool_use_id,
718 content,
719 ..
720 },
721 ] if command_ids.contains(tool_use_id.as_str())
722 || !called.contains(tool_use_id.as_str()) =>
723 {
724 let text = match content {
725 ToolOutput::Text(text) => text.clone(),
726 ToolOutput::Json(value) => value.to_string(),
727 };
728 let mut line = Map::new();
729 let mut put = |key: &str, value: Value| {
730 line.insert(key.to_string(), value);
731 };
732 put("type", Value::String("system".into()));
733 put("subtype", Value::String("local_command".into()));
734 put(
735 "content",
736 Value::String(format!(
737 "<local-command-stdout>{}</local-command-stdout>",
738 escape_envelope(&text)
739 )),
740 );
741 put("level", Value::String("info".into()));
742 put("isMeta", Value::Bool(false));
743 put("uuid", Value::String(uuid.to_string()));
744 put("timestamp", Value::String(timestamp));
745 put("sessionId", Value::String(session_id.to_string()));
746 for (key, value) in [
747 ("parentUuid", parent_uuid.map(String::from)),
748 ("cwd", meta.cwd.clone()),
749 ("gitBranch", meta.git_branch.clone()),
750 ("version", meta.cli_version.clone()),
751 ] {
752 if let Some(value) = value {
753 put(key, Value::String(value));
754 }
755 }
756 Some(Record::Other(Value::Object(line)))
757 }
758 _ => None,
760 }
761}
762
763fn strip_ansi(text: &str) -> String {
767 let mut out = String::with_capacity(text.len());
768 let mut chars = text.chars();
769 while let Some(c) = chars.next() {
770 if c != '\u{1b}' {
771 out.push(c);
772 continue;
773 }
774 match chars.next() {
775 Some('[') => {
777 for c in chars.by_ref() {
778 if matches!(c, '\u{40}'..='\u{7e}') {
779 break;
780 }
781 }
782 }
783 Some(']') => {
785 let mut escaped = false;
786 for c in chars.by_ref() {
787 if c == '\u{7}' || (escaped && c == '\\') {
788 break;
789 }
790 escaped = c == '\u{1b}';
791 }
792 }
793 Some(_) | None => {}
795 }
796 }
797 out
798}
799
800fn parse_blocks(content: &Value) -> Vec<Block> {
803 match content {
804 Value::String(s) => {
805 if s.is_empty() {
806 Vec::new()
807 } else {
808 vec![Block::Text { text: s.clone() }]
809 }
810 }
811 Value::Array(arr) => arr.iter().filter_map(parse_block).collect(),
812 Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => Vec::new(),
815 }
816}
817
818fn parse_block(v: &Value) -> Option<Block> {
819 match v.get("type").and_then(Value::as_str)? {
820 "text" => Some(Block::Text {
821 text: v.get("text")?.as_str()?.to_string(),
822 }),
823 "thinking" => Some(Block::Thinking {
824 text: v.get("thinking")?.as_str()?.to_string(),
825 signature: v.get("signature").and_then(Value::as_str).map(String::from),
826 encrypted: None,
827 }),
828 "tool_use" => {
829 let id = v.get("id")?.as_str()?.to_string();
830 let name = v.get("name")?.as_str()?;
831 let input = v.get("input").cloned().unwrap_or(Value::Object(Map::new()));
832 Some(Block::ToolUse {
833 id,
834 tool: Tool::from_canonical(name, input),
835 })
836 }
837 "tool_result" => Some(Block::ToolResult {
838 tool_use_id: v.get("tool_use_id")?.as_str()?.to_string(),
839 content: parse_tool_output(v.get("content")),
840 is_error: v.get("is_error").and_then(Value::as_bool).unwrap_or(false),
841 }),
842 "image" => {
843 let source = v.get("source")?;
844 Some(Block::Image {
845 source: ImageSource {
846 source_type: source
847 .get("type")
848 .and_then(Value::as_str)
849 .unwrap_or("base64")
850 .to_string(),
851 media_type: source.get("media_type")?.as_str()?.to_string(),
852 data: source.get("data")?.as_str()?.to_string(),
853 },
854 })
855 }
856 _ => None,
859 }
860}
861
862fn serialize_blocks(blocks: &[Block]) -> Value {
863 Value::Array(blocks.iter().map(serialize_block).collect())
864}
865
866fn serialize_block(block: &Block) -> Value {
867 match block {
868 Block::Text { text } => serde_json::json!({"type": "text", "text": text}),
869 Block::Thinking {
870 text, signature, ..
871 } => {
872 let mut obj = serde_json::json!({"type": "thinking", "thinking": text});
873 if let Some(sig) = signature {
874 obj["signature"] = Value::String(sig.clone());
875 }
876 obj
877 }
878 Block::ToolUse { id, tool } => {
879 let (name, input) = tool.to_canonical();
880 serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
881 }
882 Block::ToolResult {
883 tool_use_id,
884 content,
885 is_error,
886 } => {
887 let mut obj = serde_json::json!({
888 "type": "tool_result",
889 "tool_use_id": tool_use_id,
890 "content": serialize_tool_output(content),
891 });
892 if *is_error {
893 obj["is_error"] = Value::Bool(true);
894 }
895 obj
896 }
897 Block::Image { source } => serde_json::json!({
898 "type": "image",
899 "source": {
900 "type": source.source_type,
901 "media_type": source.media_type,
902 "data": source.data,
903 },
904 }),
905 }
906}
907
908fn parse_tool_output(content: Option<&Value>) -> ToolOutput {
909 match content {
910 Some(Value::String(s)) => ToolOutput::Text(s.clone()),
911 Some(other) => ToolOutput::Json(other.clone()),
912 None => ToolOutput::Text(String::new()),
913 }
914}
915
916fn serialize_tool_output(out: &ToolOutput) -> Value {
917 match out {
918 ToolOutput::Text(s) => Value::String(s.clone()),
919 ToolOutput::Json(v) if is_block_array(v) => v.clone(),
924 ToolOutput::Json(v) => Value::String(v.to_string()),
925 }
926}
927
928fn is_block_array(v: &Value) -> bool {
931 v.as_array().is_some_and(|arr| {
932 arr.iter()
933 .all(|b| b.get("type").and_then(Value::as_str).is_some())
934 })
935}
936
937fn parse_usage(v: &Value) -> Option<Usage> {
938 Some(Usage {
939 input_tokens: v.get("input_tokens")?.as_u64()?,
940 output_tokens: v.get("output_tokens")?.as_u64()?,
941 cache_read_input_tokens: v.get("cache_read_input_tokens").and_then(Value::as_u64),
942 cache_creation_input_tokens: v.get("cache_creation_input_tokens").and_then(Value::as_u64),
943 })
944}
945
946fn serialize_usage(u: &Usage) -> Value {
947 let mut obj = serde_json::json!({
948 "input_tokens": u.input_tokens,
949 "output_tokens": u.output_tokens,
950 });
951 if let Some(read) = u.cache_read_input_tokens {
952 obj["cache_read_input_tokens"] = read.into();
953 }
954 if let Some(write) = u.cache_creation_input_tokens {
955 obj["cache_creation_input_tokens"] = write.into();
956 }
957 obj
958}
959
960fn parse_stop_reason(s: &str) -> StopReason {
962 match s {
963 "end_turn" => StopReason::EndTurn,
964 "tool_use" => StopReason::ToolUse,
965 "max_tokens" => StopReason::MaxTokens,
966 "stop_sequence" => StopReason::StopSequence,
967 other => StopReason::Other(other.to_string()),
968 }
969}
970
971fn stop_reason_str(r: &StopReason) -> String {
972 match r {
973 StopReason::EndTurn => "end_turn".into(),
974 StopReason::ToolUse => "tool_use".into(),
975 StopReason::MaxTokens => "max_tokens".into(),
976 StopReason::StopSequence => "stop_sequence".into(),
977 StopReason::Aborted => "aborted".into(),
978 StopReason::Error => "error".into(),
979 StopReason::Other(s) => s.clone(),
980 }
981}
982
983fn role_str(role: Role) -> &'static str {
986 match role {
987 Role::User => "user",
988 Role::Assistant => "assistant",
989 }
990}
991
992fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
993 s.parse::<DateTime<Utc>>().ok()
994}
995
996fn entry_uuid(session_id: &str, index: usize) -> String {
999 const NS: Uuid = Uuid::from_bytes([
1000 0x9f, 0x0d, 0x98, 0x36, 0x9e, 0xe7, 0x4c, 0x62, 0x83, 0xb4, 0xfb, 0x8e, 0x01, 0x36, 0x5c,
1001 0x9f,
1002 ]);
1003 Uuid::new_v5(&NS, format!("{session_id}:{index}").as_bytes()).to_string()
1004}
1005
1006fn meta_from_records(records: &[Record]) -> Meta {
1009 let mut meta = Meta {
1010 id: String::new(),
1011 timestamp: Utc::now(),
1012 cwd: None,
1013 git_branch: None,
1014 title: None,
1015 cli_version: None,
1016 model: None,
1017 };
1018 let mut summary: Option<String> = None;
1019 let mut custom_title: Option<String> = None;
1020 let mut earliest: Option<DateTime<Utc>> = None;
1021
1022 for record in records {
1023 match record {
1024 Record::User(e) => {
1025 if let Some(id) = &e.session_id
1026 && meta.id.is_empty()
1027 {
1028 meta.id.clone_from(id);
1029 }
1030 meta.cwd = meta.cwd.take().or_else(|| e.cwd.clone());
1031 meta.git_branch = meta.git_branch.take().or_else(|| e.git_branch.clone());
1032 meta.cli_version = meta.cli_version.take().or_else(|| e.version.clone());
1033 note_ts(&mut earliest, e.timestamp.as_deref());
1034 }
1035 Record::Assistant(e) => {
1036 if meta.model.is_none() {
1037 meta.model.clone_from(&e.message.model);
1038 }
1039 note_ts(&mut earliest, e.timestamp.as_deref());
1040 }
1041 Record::Summary(s) => {
1042 if summary.is_none() {
1043 summary = Some(s.summary.clone());
1044 }
1045 }
1046 Record::Other(v) => match v.get("type").and_then(Value::as_str) {
1047 Some("custom-title") => {
1048 custom_title = v
1049 .get("customTitle")
1050 .and_then(Value::as_str)
1051 .map(String::from);
1052 }
1053 Some("agent-name") if custom_title.is_none() => {
1054 custom_title = v.get("agentName").and_then(Value::as_str).map(String::from);
1055 }
1056 _ => {}
1058 },
1059 }
1060 }
1061
1062 if let Some(ts) = earliest {
1063 meta.timestamp = ts;
1064 }
1065 meta.title = custom_title.or(summary);
1066 meta
1067}
1068
1069fn note_ts(earliest: &mut Option<DateTime<Utc>>, ts: Option<&str>) {
1070 if let Some(parsed) = ts.and_then(parse_ts)
1071 && earliest.is_none_or(|e| parsed < e)
1072 {
1073 *earliest = Some(parsed);
1074 }
1075}
1076
1077#[derive(Deserialize)]
1085struct MetaEntryLine {
1086 #[serde(rename = "parentUuid", default)]
1087 parent_uuid: Option<String>,
1088 uuid: String,
1089 #[serde(default)]
1090 timestamp: Option<String>,
1091 #[serde(rename = "sessionId", default)]
1092 session_id: Option<String>,
1093 #[serde(default)]
1094 cwd: Option<String>,
1095 #[serde(rename = "gitBranch", default)]
1096 git_branch: Option<String>,
1097 #[serde(default)]
1098 version: Option<String>,
1099 message: MetaApiMessage,
1100}
1101
1102#[derive(Deserialize)]
1105struct MetaApiMessage {
1106 #[serde(default)]
1107 role: Option<String>,
1108 #[allow(dead_code)] content: serde::de::IgnoredAny,
1110 #[serde(default)]
1111 model: Option<String>,
1112 #[serde(default)]
1113 stop_reason: Option<String>,
1114}
1115
1116impl From<MetaEntryLine> for EntryLine {
1118 fn from(m: MetaEntryLine) -> EntryLine {
1119 EntryLine {
1120 parent_uuid: m.parent_uuid,
1121 uuid: m.uuid,
1122 timestamp: m.timestamp,
1123 session_id: m.session_id,
1124 cwd: m.cwd,
1125 git_branch: m.git_branch,
1126 version: m.version,
1127 message: ApiMessage {
1128 role: m.message.role,
1129 content: Value::Null,
1130 model: m.message.model,
1131 stop_reason: m.message.stop_reason,
1132 usage: None,
1133 extra: Map::new(),
1134 },
1135 extra: Map::new(),
1136 }
1137 }
1138}
1139
1140fn record_from_line(line: &str) -> Option<Record> {
1146 let other = || serde_json::from_str::<Value>(line).ok().map(Record::Other);
1147 match serde_json::from_str::<jsonl::TypeProbe>(line) {
1148 Err(_) => other(),
1151 Ok(probe) => match probe.kind.as_deref() {
1152 Some("summary") => serde_json::from_str(line)
1153 .ok()
1154 .map(Record::Summary)
1155 .or_else(other),
1156 Some("user") => serde_json::from_str(line)
1157 .ok()
1158 .map(Record::User)
1159 .or_else(other),
1160 Some("assistant") => serde_json::from_str(line)
1161 .ok()
1162 .map(Record::Assistant)
1163 .or_else(other),
1164 Some(_) | None => other(),
1165 },
1166 }
1167}
1168
1169fn scan_line(line: &str) -> Option<Record> {
1175 let probe: jsonl::TypeProbe = serde_json::from_str(line).ok()?;
1176 match probe.kind.as_deref() {
1177 Some("user") => serde_json::from_str::<MetaEntryLine>(line)
1178 .ok()
1179 .map(|m| Record::User(m.into())),
1180 Some("assistant") => serde_json::from_str::<MetaEntryLine>(line)
1181 .ok()
1182 .map(|m| Record::Assistant(m.into())),
1183 Some("summary") => serde_json::from_str::<SummaryLine>(line)
1184 .ok()
1185 .map(Record::Summary),
1186 Some("custom-title" | "agent-name") => serde_json::from_str(line).ok().map(Record::Other),
1187 Some(_) | None => None,
1189 }
1190}
1191
1192fn meta_from_text(text: &str) -> Meta {
1195 let records: Vec<Record> = text
1196 .lines()
1197 .filter(|line| !line.trim().is_empty())
1198 .filter_map(scan_line)
1199 .collect();
1200 meta_from_records(&records)
1201}
1202
1203fn encode_project_dir(path: &str) -> String {
1207 path.chars()
1208 .map(|c| {
1209 if matches!(c, '/' | '.' | '\\' | ':') {
1210 '-'
1211 } else {
1212 c
1213 }
1214 })
1215 .collect()
1216}
1217
1218fn file_fingerprint(path: &Path) -> String {
1219 match fs::metadata(path) {
1220 Err(_) => String::new(),
1222 Ok(meta) => {
1223 let mtime = meta
1224 .modified()
1225 .ok()
1226 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1227 .map_or(0, |d| d.as_nanos());
1228 format!("{mtime}:{}", meta.len())
1229 }
1230 }
1231}
1232
1233fn dirs_home() -> Option<PathBuf> {
1234 super::home_dir()
1235}
1236
1237#[allow(dead_code)]
1239fn unconvertible(detail: impl Into<String>) -> Error {
1240 Error::Unconvertible {
1241 harness: ClaudeCode::NAME,
1242 detail: detail.into(),
1243 }
1244}