1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use uuid::Uuid;
4
5use super::content_blocks::{deserialize_content_blocks, ContentBlock};
6
7pub(crate) fn serialize_optional_uuid<S>(
9 uuid: &Option<Uuid>,
10 serializer: S,
11) -> Result<S::Ok, S::Error>
12where
13 S: Serializer,
14{
15 match uuid {
16 Some(id) => serializer.serialize_str(&id.to_string()),
17 None => serializer.serialize_none(),
18 }
19}
20
21pub(crate) fn deserialize_optional_uuid<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
23where
24 D: Deserializer<'de>,
25{
26 let opt_str: Option<String> = Option::deserialize(deserializer)?;
27 match opt_str {
28 Some(s) => Uuid::parse_str(&s)
29 .map(Some)
30 .map_err(serde::de::Error::custom),
31 None => Ok(None),
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct UserMessage {
38 pub message: MessageContent,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 #[serde(
41 serialize_with = "serialize_optional_uuid",
42 deserialize_with = "deserialize_optional_uuid"
43 )]
44 pub session_id: Option<Uuid>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageContent {
50 pub role: String,
51 #[serde(deserialize_with = "deserialize_content_blocks")]
52 pub content: Vec<ContentBlock>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SystemMessage {
58 pub subtype: String,
59 #[serde(flatten)]
60 pub data: Value, }
62
63impl SystemMessage {
64 pub fn is_init(&self) -> bool {
66 self.subtype == "init"
67 }
68
69 pub fn is_status(&self) -> bool {
71 self.subtype == "status"
72 }
73
74 pub fn is_compact_boundary(&self) -> bool {
76 self.subtype == "compact_boundary"
77 }
78
79 pub fn as_init(&self) -> Option<InitMessage> {
81 if self.subtype != "init" {
82 return None;
83 }
84 serde_json::from_value(self.data.clone()).ok()
85 }
86
87 pub fn as_status(&self) -> Option<StatusMessage> {
89 if self.subtype != "status" {
90 return None;
91 }
92 serde_json::from_value(self.data.clone()).ok()
93 }
94
95 pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
97 if self.subtype != "compact_boundary" {
98 return None;
99 }
100 serde_json::from_value(self.data.clone()).ok()
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PluginInfo {
107 pub name: String,
109 pub path: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InitMessage {
116 pub session_id: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub cwd: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub model: Option<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub tools: Vec<String>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub mcp_servers: Vec<Value>,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub slash_commands: Vec<String>,
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub agents: Vec<String>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub plugins: Vec<PluginInfo>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub skills: Vec<Value>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub claude_code_version: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
147 pub api_key_source: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub output_style: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
153 pub permission_mode: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct StatusMessage {
159 pub session_id: String,
161 pub status: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub uuid: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct CompactBoundaryMessage {
171 pub session_id: String,
173 pub compact_metadata: CompactMetadata,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub uuid: Option<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct CompactMetadata {
183 pub pre_tokens: u64,
185 pub trigger: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AssistantMessage {
192 pub message: AssistantMessageContent,
193 pub session_id: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub uuid: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub parent_tool_use_id: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AssistantMessageContent {
203 pub id: String,
204 pub role: String,
205 pub model: String,
206 pub content: Vec<ContentBlock>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub stop_reason: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub stop_sequence: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub usage: Option<AssistantUsage>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AssistantUsage {
218 #[serde(default)]
220 pub input_tokens: u32,
221
222 #[serde(default)]
224 pub output_tokens: u32,
225
226 #[serde(default)]
228 pub cache_creation_input_tokens: u32,
229
230 #[serde(default)]
232 pub cache_read_input_tokens: u32,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub service_tier: Option<String>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub cache_creation: Option<CacheCreationDetails>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CacheCreationDetails {
246 #[serde(default)]
248 pub ephemeral_1h_input_tokens: u32,
249
250 #[serde(default)]
252 pub ephemeral_5m_input_tokens: u32,
253}
254
255#[cfg(test)]
256mod tests {
257 use crate::io::ClaudeOutput;
258
259 #[test]
260 fn test_system_message_init() {
261 let json = r#"{
262 "type": "system",
263 "subtype": "init",
264 "session_id": "test-session-123",
265 "cwd": "/home/user/project",
266 "model": "claude-sonnet-4",
267 "tools": ["Bash", "Read", "Write"],
268 "mcp_servers": [],
269 "slash_commands": ["compact", "cost", "review"],
270 "agents": ["Bash", "Explore", "Plan"],
271 "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
272 "skills": [],
273 "claude_code_version": "2.1.15",
274 "apiKeySource": "none",
275 "output_style": "default",
276 "permissionMode": "default"
277 }"#;
278
279 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
280 if let ClaudeOutput::System(sys) = output {
281 assert!(sys.is_init());
282 assert!(!sys.is_status());
283 assert!(!sys.is_compact_boundary());
284
285 let init = sys.as_init().expect("Should parse as init");
286 assert_eq!(init.session_id, "test-session-123");
287 assert_eq!(init.cwd, Some("/home/user/project".to_string()));
288 assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
289 assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
290 assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
291 assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
292 assert_eq!(init.plugins.len(), 1);
293 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
294 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
295 assert_eq!(init.api_key_source, Some("none".to_string()));
296 assert_eq!(init.output_style, Some("default".to_string()));
297 assert_eq!(init.permission_mode, Some("default".to_string()));
298 } else {
299 panic!("Expected System message");
300 }
301 }
302
303 #[test]
304 fn test_system_message_init_from_real_capture() {
305 let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
306 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
307 if let ClaudeOutput::System(sys) = output {
308 let init = sys.as_init().expect("Should parse real init capture");
309 assert_eq!(init.slash_commands.len(), 8);
310 assert!(init.slash_commands.contains(&"compact".to_string()));
311 assert!(init.slash_commands.contains(&"review".to_string()));
312 assert_eq!(init.agents.len(), 5);
313 assert!(init.agents.contains(&"Bash".to_string()));
314 assert!(init.agents.contains(&"Explore".to_string()));
315 assert_eq!(init.plugins.len(), 1);
316 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
317 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
318 } else {
319 panic!("Expected System message");
320 }
321 }
322
323 #[test]
324 fn test_system_message_status() {
325 let json = r#"{
326 "type": "system",
327 "subtype": "status",
328 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
329 "status": "compacting",
330 "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
331 }"#;
332
333 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
334 if let ClaudeOutput::System(sys) = output {
335 assert!(sys.is_status());
336 assert!(!sys.is_init());
337
338 let status = sys.as_status().expect("Should parse as status");
339 assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
340 assert_eq!(status.status, Some("compacting".to_string()));
341 assert_eq!(
342 status.uuid,
343 Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
344 );
345 } else {
346 panic!("Expected System message");
347 }
348 }
349
350 #[test]
351 fn test_system_message_status_null() {
352 let json = r#"{
353 "type": "system",
354 "subtype": "status",
355 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
356 "status": null,
357 "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
358 }"#;
359
360 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
361 if let ClaudeOutput::System(sys) = output {
362 let status = sys.as_status().expect("Should parse as status");
363 assert_eq!(status.status, None);
364 } else {
365 panic!("Expected System message");
366 }
367 }
368
369 #[test]
370 fn test_system_message_compact_boundary() {
371 let json = r#"{
372 "type": "system",
373 "subtype": "compact_boundary",
374 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
375 "compact_metadata": {
376 "pre_tokens": 155285,
377 "trigger": "auto"
378 },
379 "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
380 }"#;
381
382 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
383 if let ClaudeOutput::System(sys) = output {
384 assert!(sys.is_compact_boundary());
385 assert!(!sys.is_init());
386 assert!(!sys.is_status());
387
388 let compact = sys
389 .as_compact_boundary()
390 .expect("Should parse as compact_boundary");
391 assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
392 assert_eq!(compact.compact_metadata.pre_tokens, 155285);
393 assert_eq!(compact.compact_metadata.trigger, "auto");
394 } else {
395 panic!("Expected System message");
396 }
397 }
398}