Skip to main content

txcript/harness/
claude_code.rs

1//! Claude Code: `~/.claude/projects/<encoded-cwd>/<session>.jsonl`.
2//!
3//! Claude's on-disk payload is already the Anthropic Messages shape, so the
4//! codec is close to the identity — it is the reference every other harness
5//! normalizes toward. Each line is one [`Record`]; user/assistant lines carry
6//! an [`ApiMessage`] whose `content` is a string or an array of Anthropic
7//! blocks. Non-message lines (summaries, titles, snapshots) are preserved
8//! verbatim in [`Record::Other`] so native ↔ disk stays lossless even though
9//! the codec ignores them.
10
11use 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/// The Claude Code harness marker.
26#[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// ── native records ─────────────────────────────────────────────────────
35
36/// One JSONL line. Known line types are typed; anything else is kept whole in
37/// [`Record::Other`] so the file round-trips without loss.
38#[derive(Debug, Clone, PartialEq)]
39pub enum Record {
40    Summary(SummaryLine),
41    User(EntryLine),
42    Assistant(EntryLine),
43    Other(Value),
44}
45
46/// A `user` or `assistant` line: the per-line envelope plus the wrapped
47/// Anthropic message. Unknown envelope keys collect in `extra`.
48#[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/// A `summary` line.
73#[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/// The wrapped Anthropic message. `content` is preserved as raw JSON (string or
81/// block array); the codec is what turns it into typed [`Block`]s.
82#[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
97// Classify by type; malformed known records fall back to Other.
98impl 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
145// ── codec ──────────────────────────────────────────────────────────────
146
147impl 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                // Summaries, titles, snapshots carry no conversational turn.
157                Record::Summary(_) | Record::Other(_) => None,
158            })
159            .filter_map(|(role, entry)| {
160                let content = parse_blocks(&entry.message.content);
161                // An entry whose blocks parse to nothing carries no turn
162                // either.
163                (!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        // A leading summary gives `claude --resume` a friendly title.
190        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        // Not jsonl::parse: that would route every line through Record's
236        // Deserialize and its intermediate `Value` tree. Typed lines here
237        // parse straight into their structs.
238        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// ── store ──────────────────────────────────────────────────────────────
253
254/// Reads and writes Claude Code sessions under a projects root (default
255/// `~/.claude/projects`).
256#[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    /// The default projects root: `$CLAUDE_CONFIG_DIR/projects` when set
267    /// (every `~/.claude` path moves under that directory), else
268    /// `~/.claude/projects`.
269    #[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        // An unreadable directory lists nothing.
279        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                // Subagent and tool-result side-files aren't top-level sessions.
285                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                    // A session that fails to read is skipped, not fatal. Meta
307                    // comes from the shallow scan, not a full parse.
308                    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            // A missing root means no sessions, not an error.
322            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
360// ── block <-> value ────────────────────────────────────────────────────
361
362fn 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        // Content is a string or a block array on the wire; any other JSON
373        // shape carries no blocks.
374        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        // Block types we don't model carry nothing into the canonical model
417        // (the native `Record` still round-trips them).
418        _ => 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        // The Anthropic wire accepts only a string or a content-block array
480        // in `tool_result.content`; a bare object fails the whole session
481        // load on resume. Keep block arrays (Claude's own native shape),
482        // flatten any other JSON to its compact text.
483        ToolOutput::Json(v) if is_block_array(v) => v.clone(),
484        ToolOutput::Json(v) => Value::String(v.to_string()),
485    }
486}
487
488/// Whether a value is an Anthropic content-block array — the only non-string
489/// shape Claude Code itself writes into `tool_result.content`.
490fn 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
520// Claude's stop_reason strings are the canonical Anthropic set.
521fn 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
543// ── helpers ────────────────────────────────────────────────────────────
544
545fn 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
556/// Deterministic per-entry uuid, so `from_common` is a pure function of the
557/// transcript (no randomness, reproducible conversions and tests).
558fn 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
566/// Extract session metadata from the records. `id` is left empty when no
567/// `sessionId` is present; a [`Store`] fills it from the filename.
568fn 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                // Other line types carry no session metadata.
617                _ => {}
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// ── discover: shallow meta scan ────────────────────────────────────────
638
639/// Shallow mirror of [`EntryLine`] for discovery: the same typed fields with
640/// the same types — so a line that fails the full parse fails here too — but
641/// the payload-bearing `message.content` is only presence-checked, never
642/// built. Fields the full parse tolerates unconditionally (`extra`, `usage`)
643/// are covered by serde's ignore-unknown default.
644#[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/// Shallow mirror of [`ApiMessage`]: `content` must be present (as in the
663/// full parse) but its value is skipped, not built.
664#[derive(Deserialize)]
665struct MetaApiMessage {
666    #[serde(default)]
667    role: Option<String>,
668    #[allow(dead_code)] // presence check only; the value is never built
669    content: serde::de::IgnoredAny,
670    #[serde(default)]
671    model: Option<String>,
672    #[serde(default)]
673    stop_reason: Option<String>,
674}
675
676// A skeleton [`EntryLine`] carrying only what [`meta_from_records`] reads.
677impl 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
700/// Parse one line straight into its typed record — no intermediate [`Value`]
701/// tree. Only lines outside the typed set pay a second parse into the
702/// preserved [`Record::Other`] `Value`: an unknown or non-string tag, or the
703/// rare known-tag line whose body fails its typed schema. Invalid JSON
704/// parses to `None` and is skipped, exactly as under [`jsonl::parse`].
705fn 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        // A failed probe isn't necessarily junk: a non-string `type` also
709        // fails it. The fallback keeps such lines whole and skips the rest.
710        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
729/// Parse one line only as far as discovery needs: user/assistant lines
730/// through the shallow mirrors, summary/custom-title/agent-name lines whole
731/// (each is one short line). Everything else — malformed lines included —
732/// lands in [`Record::Other`] under the full parse and contributes no
733/// metadata there, so here it parses to `None`.
734fn 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        // No other line type carries session metadata.
748        Some(_) | None => None,
749    }
750}
751
752/// [`meta_from_records`] over a shallow scan of the raw text — equivalent to
753/// `from_text(text)?.meta`, but discovery never builds message payloads.
754fn 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
763/// Claude's project-dir encoding: every `/` and `.` becomes `-`. Windows
764/// cwds encode the same way with `\` and the drive `:` also mapped
765/// (`C:\Users\x` → `C--Users-x`).
766fn 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        // An unreadable file has no fingerprint.
781        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// Surface the canonical-name guard as a typed error for callers that care.
798#[allow(dead_code)]
799fn unconvertible(detail: impl Into<String>) -> Error {
800    Error::Unconvertible {
801        harness: ClaudeCode::NAME,
802        detail: detail.into(),
803    }
804}