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}