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 messages = transcript
151 .body
152 .iter()
153 .filter_map(|record| match record {
154 Record::User(e) => Some((Role::User, e)),
155 Record::Assistant(e) => Some((Role::Assistant, e)),
156 Record::Summary(_) | Record::Other(_) => None,
158 })
159 .filter_map(|(role, entry)| {
160 let content = parse_blocks(&entry.message.content);
161 (!content.is_empty()).then(|| Message {
164 role,
165 content,
166 timestamp: entry
167 .timestamp
168 .as_deref()
169 .and_then(parse_ts)
170 .unwrap_or(fallback_ts),
171 model: entry.message.model.clone(),
172 stop_reason: entry.message.stop_reason.as_deref().map(parse_stop_reason),
173 usage: entry.message.usage.as_ref().and_then(parse_usage),
174 })
175 })
176 .collect();
177 Ok(Transcript::new(transcript.meta.clone(), messages))
178 }
179
180 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
181 let meta = &transcript.meta;
182 let session_id = if meta.id.is_empty() {
183 Uuid::new_v4().to_string()
184 } else {
185 meta.id.clone()
186 };
187 let mut records = Vec::with_capacity(transcript.body.len() + 1);
188
189 if let Some(title) = meta.title.as_deref().filter(|t| !t.is_empty()) {
191 records.push(Record::Summary(SummaryLine {
192 summary: title.to_string(),
193 extra: Map::from_iter([(
194 "leafUuid".into(),
195 Value::String(entry_uuid(&session_id, usize::MAX)),
196 )]),
197 }));
198 }
199
200 let mut parent_uuid: Option<String> = None;
201 for (i, msg) in transcript.body.iter().enumerate() {
202 let uuid = entry_uuid(&session_id, i);
203 let api = ApiMessage {
204 role: Some(role_str(msg.role).to_string()),
205 content: serialize_blocks(&msg.content),
206 model: msg.model.clone(),
207 stop_reason: msg.stop_reason.as_ref().map(stop_reason_str),
208 usage: msg.usage.as_ref().map(serialize_usage),
209 extra: Map::new(),
210 };
211 let entry = EntryLine {
212 parent_uuid: parent_uuid.clone(),
213 uuid: uuid.clone(),
214 timestamp: Some(msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)),
215 session_id: Some(session_id.clone()),
216 cwd: meta.cwd.clone(),
217 git_branch: meta.git_branch.clone(),
218 version: meta.cli_version.clone(),
219 message: api,
220 extra: Map::new(),
221 };
222 records.push(match msg.role {
223 Role::User => Record::User(entry),
224 Role::Assistant => Record::Assistant(entry),
225 });
226 parent_uuid = Some(uuid);
227 }
228
229 Ok(Transcript::new(meta.clone(), records))
230 }
231}
232
233impl TextCodec for ClaudeCode {
234 fn from_text(text: &str) -> Result<Transcript<Self>> {
235 let records: Vec<Record> = text
239 .lines()
240 .filter(|line| !line.trim().is_empty())
241 .filter_map(record_from_line)
242 .collect();
243 let meta = meta_from_records(&records);
244 Ok(Transcript::new(meta, records))
245 }
246
247 fn to_text(transcript: &Transcript<Self>) -> Result<String> {
248 jsonl::render(&transcript.body)
249 }
250}
251
252#[derive(Debug, Clone)]
257pub struct ClaudeStore {
258 pub root: PathBuf,
259}
260
261impl ClaudeStore {
262 pub fn new(root: impl Into<PathBuf>) -> Self {
263 Self { root: root.into() }
264 }
265
266 #[must_use]
270 pub fn default_root() -> Option<Self> {
271 std::env::var_os("CLAUDE_CONFIG_DIR")
272 .filter(|v| !v.is_empty())
273 .map(|dir| Self::new(PathBuf::from(dir).join("projects")))
274 .or_else(|| dirs_home().map(|h| Self::new(h.join(".claude").join("projects"))))
275 }
276
277 fn collect_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
278 for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
280 let path = entry.path();
281 if path.is_dir() {
282 let name = entry.file_name();
283 let name = name.to_string_lossy();
284 if name != "subagents" && name != "tool-results" {
286 Self::collect_jsonl(&path, out);
287 }
288 } else if path.extension().is_some_and(|e| e == "jsonl") {
289 out.push(path);
290 }
291 }
292 }
293}
294
295impl Store for ClaudeStore {
296 type H = ClaudeCode;
297 type Ref = PathBuf;
298
299 fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
300 if self.root.is_dir() {
301 let mut files = Vec::new();
302 Self::collect_jsonl(&self.root, &mut files);
303 Ok(files
304 .into_iter()
305 .filter_map(|path| {
306 fs::read_to_string(&path).ok().map(|text| {
309 let mut meta = meta_from_text(&text);
310 if meta.id.is_empty() {
311 meta.id = jsonl::file_id(&path);
312 }
313 Discovered {
314 meta,
315 reference: path,
316 }
317 })
318 })
319 .collect())
320 } else {
321 Ok(Vec::new())
323 }
324 }
325
326 fn load(&self, reference: &PathBuf) -> Result<Transcript<ClaudeCode>> {
327 let mut transcript = ClaudeCode::from_text(&fs::read_to_string(reference)?)?;
328 if transcript.meta.id.is_empty() {
329 transcript.meta.id = jsonl::file_id(reference);
330 }
331 Ok(transcript)
332 }
333
334 fn save(&self, transcript: &Transcript<ClaudeCode>) -> Result<Saved<PathBuf>> {
335 let cwd = transcript.meta.cwd.as_deref().unwrap_or_default();
336 let dir = self.root.join(encode_project_dir(cwd));
337 fs::create_dir_all(&dir)?;
338 let id = transcript.meta.id.clone();
339 let path = dir.join(format!("{id}.jsonl"));
340 fs::write(&path, ClaudeCode::to_text(transcript)?)?;
341 Ok(Saved {
342 id,
343 reference: path,
344 })
345 }
346
347 fn delete(&self, reference: &PathBuf) -> Result<()> {
348 Ok(fs::remove_file(reference)?)
349 }
350
351 fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
352 let mut out = HashMap::with_capacity(refs.len());
353 for path in refs {
354 out.insert(path.to_string_lossy().into_owned(), file_fingerprint(path));
355 }
356 Ok(out)
357 }
358}
359
360fn parse_blocks(content: &Value) -> Vec<Block> {
363 match content {
364 Value::String(s) => {
365 if s.is_empty() {
366 Vec::new()
367 } else {
368 vec![Block::Text { text: s.clone() }]
369 }
370 }
371 Value::Array(arr) => arr.iter().filter_map(parse_block).collect(),
372 Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => Vec::new(),
375 }
376}
377
378fn parse_block(v: &Value) -> Option<Block> {
379 match v.get("type").and_then(Value::as_str)? {
380 "text" => Some(Block::Text {
381 text: v.get("text")?.as_str()?.to_string(),
382 }),
383 "thinking" => Some(Block::Thinking {
384 text: v.get("thinking")?.as_str()?.to_string(),
385 signature: v.get("signature").and_then(Value::as_str).map(String::from),
386 encrypted: None,
387 }),
388 "tool_use" => {
389 let id = v.get("id")?.as_str()?.to_string();
390 let name = v.get("name")?.as_str()?;
391 let input = v.get("input").cloned().unwrap_or(Value::Object(Map::new()));
392 Some(Block::ToolUse {
393 id,
394 tool: Tool::from_canonical(name, input),
395 })
396 }
397 "tool_result" => Some(Block::ToolResult {
398 tool_use_id: v.get("tool_use_id")?.as_str()?.to_string(),
399 content: parse_tool_output(v.get("content")),
400 is_error: v.get("is_error").and_then(Value::as_bool).unwrap_or(false),
401 }),
402 "image" => {
403 let source = v.get("source")?;
404 Some(Block::Image {
405 source: ImageSource {
406 source_type: source
407 .get("type")
408 .and_then(Value::as_str)
409 .unwrap_or("base64")
410 .to_string(),
411 media_type: source.get("media_type")?.as_str()?.to_string(),
412 data: source.get("data")?.as_str()?.to_string(),
413 },
414 })
415 }
416 _ => None,
419 }
420}
421
422fn serialize_blocks(blocks: &[Block]) -> Value {
423 Value::Array(blocks.iter().map(serialize_block).collect())
424}
425
426fn serialize_block(block: &Block) -> Value {
427 match block {
428 Block::Text { text } => serde_json::json!({"type": "text", "text": text}),
429 Block::Thinking {
430 text, signature, ..
431 } => {
432 let mut obj = serde_json::json!({"type": "thinking", "thinking": text});
433 if let Some(sig) = signature {
434 obj["signature"] = Value::String(sig.clone());
435 }
436 obj
437 }
438 Block::ToolUse { id, tool } => {
439 let (name, input) = tool.to_canonical();
440 serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": input})
441 }
442 Block::ToolResult {
443 tool_use_id,
444 content,
445 is_error,
446 } => {
447 let mut obj = serde_json::json!({
448 "type": "tool_result",
449 "tool_use_id": tool_use_id,
450 "content": serialize_tool_output(content),
451 });
452 if *is_error {
453 obj["is_error"] = Value::Bool(true);
454 }
455 obj
456 }
457 Block::Image { source } => serde_json::json!({
458 "type": "image",
459 "source": {
460 "type": source.source_type,
461 "media_type": source.media_type,
462 "data": source.data,
463 },
464 }),
465 }
466}
467
468fn parse_tool_output(content: Option<&Value>) -> ToolOutput {
469 match content {
470 Some(Value::String(s)) => ToolOutput::Text(s.clone()),
471 Some(other) => ToolOutput::Json(other.clone()),
472 None => ToolOutput::Text(String::new()),
473 }
474}
475
476fn serialize_tool_output(out: &ToolOutput) -> Value {
477 match out {
478 ToolOutput::Text(s) => Value::String(s.clone()),
479 ToolOutput::Json(v) if is_block_array(v) => v.clone(),
484 ToolOutput::Json(v) => Value::String(v.to_string()),
485 }
486}
487
488fn is_block_array(v: &Value) -> bool {
491 v.as_array().is_some_and(|arr| {
492 arr.iter()
493 .all(|b| b.get("type").and_then(Value::as_str).is_some())
494 })
495}
496
497fn parse_usage(v: &Value) -> Option<Usage> {
498 Some(Usage {
499 input_tokens: v.get("input_tokens")?.as_u64()?,
500 output_tokens: v.get("output_tokens")?.as_u64()?,
501 cache_read_input_tokens: v.get("cache_read_input_tokens").and_then(Value::as_u64),
502 cache_creation_input_tokens: v.get("cache_creation_input_tokens").and_then(Value::as_u64),
503 })
504}
505
506fn serialize_usage(u: &Usage) -> Value {
507 let mut obj = serde_json::json!({
508 "input_tokens": u.input_tokens,
509 "output_tokens": u.output_tokens,
510 });
511 if let Some(read) = u.cache_read_input_tokens {
512 obj["cache_read_input_tokens"] = read.into();
513 }
514 if let Some(write) = u.cache_creation_input_tokens {
515 obj["cache_creation_input_tokens"] = write.into();
516 }
517 obj
518}
519
520fn parse_stop_reason(s: &str) -> StopReason {
522 match s {
523 "end_turn" => StopReason::EndTurn,
524 "tool_use" => StopReason::ToolUse,
525 "max_tokens" => StopReason::MaxTokens,
526 "stop_sequence" => StopReason::StopSequence,
527 other => StopReason::Other(other.to_string()),
528 }
529}
530
531fn stop_reason_str(r: &StopReason) -> String {
532 match r {
533 StopReason::EndTurn => "end_turn".into(),
534 StopReason::ToolUse => "tool_use".into(),
535 StopReason::MaxTokens => "max_tokens".into(),
536 StopReason::StopSequence => "stop_sequence".into(),
537 StopReason::Aborted => "aborted".into(),
538 StopReason::Error => "error".into(),
539 StopReason::Other(s) => s.clone(),
540 }
541}
542
543fn role_str(role: Role) -> &'static str {
546 match role {
547 Role::User => "user",
548 Role::Assistant => "assistant",
549 }
550}
551
552fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
553 s.parse::<DateTime<Utc>>().ok()
554}
555
556fn entry_uuid(session_id: &str, index: usize) -> String {
559 const NS: Uuid = Uuid::from_bytes([
560 0x9f, 0x0d, 0x98, 0x36, 0x9e, 0xe7, 0x4c, 0x62, 0x83, 0xb4, 0xfb, 0x8e, 0x01, 0x36, 0x5c,
561 0x9f,
562 ]);
563 Uuid::new_v5(&NS, format!("{session_id}:{index}").as_bytes()).to_string()
564}
565
566fn meta_from_records(records: &[Record]) -> Meta {
569 let mut meta = Meta {
570 id: String::new(),
571 timestamp: Utc::now(),
572 cwd: None,
573 git_branch: None,
574 title: None,
575 cli_version: None,
576 model: None,
577 };
578 let mut summary: Option<String> = None;
579 let mut custom_title: Option<String> = None;
580 let mut earliest: Option<DateTime<Utc>> = None;
581
582 for record in records {
583 match record {
584 Record::User(e) => {
585 if let Some(id) = &e.session_id
586 && meta.id.is_empty()
587 {
588 meta.id.clone_from(id);
589 }
590 meta.cwd = meta.cwd.take().or_else(|| e.cwd.clone());
591 meta.git_branch = meta.git_branch.take().or_else(|| e.git_branch.clone());
592 meta.cli_version = meta.cli_version.take().or_else(|| e.version.clone());
593 note_ts(&mut earliest, e.timestamp.as_deref());
594 }
595 Record::Assistant(e) => {
596 if meta.model.is_none() {
597 meta.model.clone_from(&e.message.model);
598 }
599 note_ts(&mut earliest, e.timestamp.as_deref());
600 }
601 Record::Summary(s) => {
602 if summary.is_none() {
603 summary = Some(s.summary.clone());
604 }
605 }
606 Record::Other(v) => match v.get("type").and_then(Value::as_str) {
607 Some("custom-title") => {
608 custom_title = v
609 .get("customTitle")
610 .and_then(Value::as_str)
611 .map(String::from);
612 }
613 Some("agent-name") if custom_title.is_none() => {
614 custom_title = v.get("agentName").and_then(Value::as_str).map(String::from);
615 }
616 _ => {}
618 },
619 }
620 }
621
622 if let Some(ts) = earliest {
623 meta.timestamp = ts;
624 }
625 meta.title = custom_title.or(summary);
626 meta
627}
628
629fn note_ts(earliest: &mut Option<DateTime<Utc>>, ts: Option<&str>) {
630 if let Some(parsed) = ts.and_then(parse_ts)
631 && earliest.is_none_or(|e| parsed < e)
632 {
633 *earliest = Some(parsed);
634 }
635}
636
637#[derive(Deserialize)]
645struct MetaEntryLine {
646 #[serde(rename = "parentUuid", default)]
647 parent_uuid: Option<String>,
648 uuid: String,
649 #[serde(default)]
650 timestamp: Option<String>,
651 #[serde(rename = "sessionId", default)]
652 session_id: Option<String>,
653 #[serde(default)]
654 cwd: Option<String>,
655 #[serde(rename = "gitBranch", default)]
656 git_branch: Option<String>,
657 #[serde(default)]
658 version: Option<String>,
659 message: MetaApiMessage,
660}
661
662#[derive(Deserialize)]
665struct MetaApiMessage {
666 #[serde(default)]
667 role: Option<String>,
668 #[allow(dead_code)] content: serde::de::IgnoredAny,
670 #[serde(default)]
671 model: Option<String>,
672 #[serde(default)]
673 stop_reason: Option<String>,
674}
675
676impl From<MetaEntryLine> for EntryLine {
678 fn from(m: MetaEntryLine) -> EntryLine {
679 EntryLine {
680 parent_uuid: m.parent_uuid,
681 uuid: m.uuid,
682 timestamp: m.timestamp,
683 session_id: m.session_id,
684 cwd: m.cwd,
685 git_branch: m.git_branch,
686 version: m.version,
687 message: ApiMessage {
688 role: m.message.role,
689 content: Value::Null,
690 model: m.message.model,
691 stop_reason: m.message.stop_reason,
692 usage: None,
693 extra: Map::new(),
694 },
695 extra: Map::new(),
696 }
697 }
698}
699
700fn record_from_line(line: &str) -> Option<Record> {
706 let other = || serde_json::from_str::<Value>(line).ok().map(Record::Other);
707 match serde_json::from_str::<jsonl::TypeProbe>(line) {
708 Err(_) => other(),
711 Ok(probe) => match probe.kind.as_deref() {
712 Some("summary") => serde_json::from_str(line)
713 .ok()
714 .map(Record::Summary)
715 .or_else(other),
716 Some("user") => serde_json::from_str(line)
717 .ok()
718 .map(Record::User)
719 .or_else(other),
720 Some("assistant") => serde_json::from_str(line)
721 .ok()
722 .map(Record::Assistant)
723 .or_else(other),
724 Some(_) | None => other(),
725 },
726 }
727}
728
729fn scan_line(line: &str) -> Option<Record> {
735 let probe: jsonl::TypeProbe = serde_json::from_str(line).ok()?;
736 match probe.kind.as_deref() {
737 Some("user") => serde_json::from_str::<MetaEntryLine>(line)
738 .ok()
739 .map(|m| Record::User(m.into())),
740 Some("assistant") => serde_json::from_str::<MetaEntryLine>(line)
741 .ok()
742 .map(|m| Record::Assistant(m.into())),
743 Some("summary") => serde_json::from_str::<SummaryLine>(line)
744 .ok()
745 .map(Record::Summary),
746 Some("custom-title" | "agent-name") => serde_json::from_str(line).ok().map(Record::Other),
747 Some(_) | None => None,
749 }
750}
751
752fn meta_from_text(text: &str) -> Meta {
755 let records: Vec<Record> = text
756 .lines()
757 .filter(|line| !line.trim().is_empty())
758 .filter_map(scan_line)
759 .collect();
760 meta_from_records(&records)
761}
762
763fn encode_project_dir(path: &str) -> String {
767 path.chars()
768 .map(|c| {
769 if matches!(c, '/' | '.' | '\\' | ':') {
770 '-'
771 } else {
772 c
773 }
774 })
775 .collect()
776}
777
778fn file_fingerprint(path: &Path) -> String {
779 match fs::metadata(path) {
780 Err(_) => String::new(),
782 Ok(meta) => {
783 let mtime = meta
784 .modified()
785 .ok()
786 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
787 .map_or(0, |d| d.as_nanos());
788 format!("{mtime}:{}", meta.len())
789 }
790 }
791}
792
793fn dirs_home() -> Option<PathBuf> {
794 super::home_dir()
795}
796
797#[allow(dead_code)]
799fn unconvertible(detail: impl Into<String>) -> Error {
800 Error::Unconvertible {
801 harness: ClaudeCode::NAME,
802 detail: detail.into(),
803 }
804}