Skip to main content

vct_core/models/
claude.rs

1//! Serde models for the Claude Code `*.jsonl` session format.
2//!
3//! Only the fields the analyzer reads are materialized; unrelated payloads are
4//! dropped during deserialization so long sessions stay cheap to parse. Several
5//! fields in this format are polymorphic (string-or-array, object-or-scalar),
6//! handled by the custom `deserialize_*` helpers below.
7
8use serde::{Deserialize, Deserializer};
9use serde_json::Value;
10
11/// Single log entry from a Claude Code session file.
12///
13/// Only fields the analyzer actually reads are materialised. Large unrelated
14/// payloads — assistant text content, `tool_result` bodies, `parentUuid`,
15/// version metadata — are dropped by serde during parse so they never retain
16/// memory, which is what keeps long sessions from ballooning the working set.
17#[derive(Debug, Clone, Default, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ClaudeCodeLog {
20    /// Working directory the session ran in.
21    #[serde(default)]
22    pub cwd: String,
23    /// Session identifier.
24    #[serde(default)]
25    pub session_id: String,
26    /// Record type discriminator (e.g. `user`, `assistant`).
27    #[serde(default, rename = "type")]
28    pub log_type: String,
29    /// ISO-8601 timestamp string for the record.
30    #[serde(default)]
31    pub timestamp: String,
32    /// The message body, when the record carries one.
33    #[serde(default)]
34    pub message: Option<ClaudeMessage>,
35    /// Legacy top-level `toolUseResult`; absent on subagent records.
36    #[serde(default, deserialize_with = "deserialize_tool_use_result")]
37    pub tool_use_result: Option<ClaudeToolUseResult>,
38    /// `true` for records inside a subagent JSONL
39    /// (`<session>/subagents/agent-*.jsonl`). Subagent records do not carry
40    /// the top-level `toolUseResult` field, so the analyzer falls back to
41    /// scanning `message.content[].tool_result` for them. Main-session
42    /// records (`isSidechain == false` or missing) skip the fallback to
43    /// avoid double-counting tool results that already arrived via
44    /// `toolUseResult`.
45    #[serde(default)]
46    pub is_sidechain: bool,
47}
48
49/// Assistant/user message with only the fields `session::claude::parse_claude_logs` inspects.
50///
51/// `content` may appear in the source as either an array of typed blocks
52/// (assistant messages) or a plain string (user messages like `"Caveat: ..."`).
53/// Only the array form carries analyzer-relevant data, so the string form is
54/// swallowed without allocating.
55#[derive(Debug, Clone, Default, Deserialize)]
56pub struct ClaudeMessage {
57    /// Model name that produced an assistant message.
58    #[serde(default)]
59    pub model: Option<String>,
60    /// Raw token-usage object as written by Claude Code.
61    #[serde(default)]
62    pub usage: Option<Value>,
63    /// Typed content blocks; empty when `content` was a plain string.
64    #[serde(default, deserialize_with = "deserialize_content_items")]
65    pub content: Vec<ClaudeContentItem>,
66}
67
68/// One element of a message's `content` array.
69///
70/// `ToolUse` carries the assistant-side invocation. `ToolResult` carries the
71/// matching result block from the *user*-role record — used as a fallback
72/// when the legacy top-level `toolUseResult` field is absent (Claude Code
73/// subagent JSONL files under `<session>/subagents/agent-*.jsonl` only
74/// embed results inside `message.content[].tool_result` blocks). Anything
75/// else (text, thinking traces, images, …) collapses to `Other` and is
76/// discarded at parse time.
77#[derive(Debug, Clone, Deserialize)]
78#[serde(tag = "type", rename_all = "snake_case")]
79pub enum ClaudeContentItem {
80    /// An assistant-side tool invocation.
81    ToolUse {
82        /// Tool-call identifier, matched against a later `ToolResult`.
83        #[serde(default)]
84        id: String,
85        /// Tool name (e.g. `Read`, `Write`, `Bash`).
86        #[serde(default)]
87        name: String,
88        /// Tool input parameters.
89        #[serde(default)]
90        input: Option<ClaudeToolInput>,
91    },
92    /// A tool result block from a user-role record (subagent fallback path).
93    ToolResult {
94        /// Identifier of the `ToolUse` this result answers.
95        #[serde(default)]
96        tool_use_id: String,
97        /// Flattened result text (string or joined array blocks).
98        #[serde(default, deserialize_with = "deserialize_tool_result_content")]
99        content: String,
100        /// Whether Claude rejected the invocation before the tool ran.
101        #[serde(default)]
102        is_error: bool,
103    },
104    /// Any other content block (text, thinking, image, …); discarded.
105    #[serde(other)]
106    Other,
107}
108
109/// Tool input across all tools we care about. Each tool only populates a
110/// subset of fields; serde silently ignores unknown fields and unset fields
111/// stay `None`. Unrelated tools (Glob, Grep, WebSearch, …) deserialize into
112/// an all-`None` value that the analyzer treats as a no-op.
113#[derive(Debug, Clone, Default, Deserialize)]
114pub struct ClaudeToolInput {
115    // Bash
116    /// Shell command line (Bash tool).
117    #[serde(default)]
118    pub command: Option<String>,
119    /// Human-readable command description (Bash tool).
120    #[serde(default)]
121    pub description: Option<String>,
122    // Read / Write / Edit share `file_path`
123    /// Target file path (Read / Write / Edit tools).
124    #[serde(default)]
125    pub file_path: Option<String>,
126    // Write
127    /// File content to write (Write tool).
128    #[serde(default)]
129    pub content: Option<String>,
130    // Edit
131    /// Text to replace (Edit tool).
132    #[serde(default)]
133    pub old_string: Option<String>,
134    /// Replacement text (Edit tool).
135    #[serde(default)]
136    pub new_string: Option<String>,
137}
138
139/// Flattens a polymorphic tool-result `content` field into a single `String`.
140///
141/// Tool-result `content` can be either a plain string or an array of typed
142/// blocks (e.g. `[{"type":"text","text":"..."}]`). Both shapes flatten to a
143/// single `String` for the analyzer's line-counting helpers; `null` becomes an
144/// empty string and any other JSON value is stringified.
145///
146/// # Errors
147///
148/// Returns a deserialization error if the underlying tokens are not valid JSON
149/// for [`Value`].
150fn deserialize_tool_result_content<'de, D>(deserializer: D) -> Result<String, D::Error>
151where
152    D: Deserializer<'de>,
153{
154    let value = Value::deserialize(deserializer)?;
155    match value {
156        Value::String(s) => Ok(s),
157        Value::Array(arr) => {
158            let texts: Vec<&str> = arr
159                .iter()
160                .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
161                .collect();
162            Ok(texts.join("\n"))
163        }
164        Value::Null => Ok(String::new()),
165        _ => Ok(value.to_string()),
166    }
167}
168
169/// Object form of `toolUseResult`. String-shaped values (user-rejection error
170/// messages, etc.) are swallowed by `deserialize_tool_use_result` without
171/// allocating their body.
172#[derive(Debug, Clone, Default, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct ClaudeToolUseResult {
175    /// Result kind discriminator, when the source provides one.
176    #[serde(default, rename = "type")]
177    pub result_type: Option<String>,
178    /// File payload for read/write results.
179    #[serde(default)]
180    pub file: Option<ClaudeToolUseFile>,
181    /// Target file path, when reported at the top level.
182    #[serde(default)]
183    pub file_path: Option<String>,
184    /// Result content body (e.g. file contents read).
185    #[serde(default)]
186    pub content: Option<String>,
187    /// Replacement text for edit results.
188    #[serde(default)]
189    pub new_string: Option<String>,
190    /// Replaced text for edit results.
191    #[serde(default)]
192    pub old_string: Option<String>,
193}
194
195/// File payload nested inside a [`ClaudeToolUseResult`] for read/write tools.
196#[derive(Debug, Clone, Default, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ClaudeToolUseFile {
199    /// Path of the file the tool acted on.
200    #[serde(default)]
201    pub file_path: Option<String>,
202    /// File content captured by the tool.
203    #[serde(default)]
204    pub content: Option<String>,
205}
206
207/// Deserializes `toolUseResult`, keeping only its object form.
208///
209/// `toolUseResult` can legally be either an object or a scalar string. The
210/// analyzer only cares about the object form, so scalar values are consumed
211/// via `IgnoredAny` (serde walks the JSON tokens but allocates nothing) and
212/// yield `None`.
213///
214/// # Errors
215///
216/// Returns a deserialization error if the field is neither a valid
217/// [`ClaudeToolUseResult`] object nor an otherwise ignorable JSON value.
218fn deserialize_tool_use_result<'de, D>(
219    deserializer: D,
220) -> Result<Option<ClaudeToolUseResult>, D::Error>
221where
222    D: Deserializer<'de>,
223{
224    use serde::de::IgnoredAny;
225
226    #[derive(Deserialize)]
227    #[serde(untagged)]
228    enum Repr {
229        Object(Box<ClaudeToolUseResult>),
230        #[allow(dead_code)]
231        Ignored(IgnoredAny),
232    }
233
234    match Option::<Repr>::deserialize(deserializer)? {
235        Some(Repr::Object(obj)) => Ok(Some(*obj)),
236        _ => Ok(None),
237    }
238}
239
240/// Deserializes `message.content`, tolerating the non-array shape.
241///
242/// `message.content` is an array for assistant turns but a plain string for
243/// some user turns (e.g. `"Caveat: ..."`). Non-array shapes carry nothing the
244/// analyzer needs, so they are consumed via `IgnoredAny` and yield an empty
245/// `Vec` rather than failing the whole record.
246///
247/// # Errors
248///
249/// Returns a deserialization error if the field is neither a valid array of
250/// [`ClaudeContentItem`] nor an otherwise ignorable JSON value.
251fn deserialize_content_items<'de, D>(deserializer: D) -> Result<Vec<ClaudeContentItem>, D::Error>
252where
253    D: Deserializer<'de>,
254{
255    use serde::de::IgnoredAny;
256
257    #[derive(Deserialize)]
258    #[serde(untagged)]
259    enum Repr {
260        Array(Vec<ClaudeContentItem>),
261        #[allow(dead_code)]
262        Ignored(IgnoredAny),
263    }
264
265    match Option::<Repr>::deserialize(deserializer)? {
266        Some(Repr::Array(arr)) => Ok(arr),
267        _ => Ok(Vec::new()),
268    }
269}