1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ClaudeStreamEvent {
16 System {
18 session_id: String,
19 model: String,
20 #[serde(default)]
21 tools: Vec<serde_json::Value>,
22 },
23
24 Assistant {
26 message: AssistantMessage,
27 #[serde(default)]
28 usage: Option<Usage>,
29 },
30
31 User { message: UserMessage },
33
34 Result {
36 duration_ms: u64,
37 total_cost_usd: f64,
38 num_turns: u32,
39 is_error: bool,
40 },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct AssistantMessage {
46 pub content: Vec<ContentBlock>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct UserMessage {
52 pub content: Vec<UserContentBlock>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum ContentBlock {
59 Text { text: String },
61 ToolUse {
63 id: String,
64 name: String,
65 input: serde_json::Value,
66 },
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum UserContentBlock {
73 ToolResult {
75 tool_use_id: String,
76 content: String,
77 },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct Usage {
83 pub input_tokens: u64,
84 pub output_tokens: u64,
85}
86
87pub struct ClaudeStreamParser;
89
90impl ClaudeStreamParser {
91 pub fn parse_line(line: &str) -> Option<ClaudeStreamEvent> {
95 let trimmed = line.trim();
96 if trimmed.is_empty() {
97 return None;
98 }
99
100 match serde_json::from_str::<ClaudeStreamEvent>(trimmed) {
101 Ok(event) => Some(event),
102 Err(e) => {
103 tracing::debug!(
104 "Skipping malformed JSON line: {} (error: {})",
105 truncate(trimmed, 100),
106 e
107 );
108 None
109 }
110 }
111 }
112}
113
114fn truncate(s: &str, max_len: usize) -> String {
116 if s.len() <= max_len {
117 s.to_string()
118 } else {
119 let boundary = s
121 .char_indices()
122 .take_while(|(i, _)| *i < max_len)
123 .last()
124 .map(|(i, c)| i + c.len_utf8())
125 .unwrap_or(0);
126 format!("{}...", &s[..boundary])
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn test_parse_system_event() {
136 let json = r#"{"type":"system","session_id":"abc123","model":"claude-opus","tools":[]}"#;
137 let event = ClaudeStreamParser::parse_line(json).unwrap();
138
139 match event {
140 ClaudeStreamEvent::System {
141 session_id,
142 model,
143 tools,
144 } => {
145 assert_eq!(session_id, "abc123");
146 assert_eq!(model, "claude-opus");
147 assert!(tools.is_empty());
148 }
149 _ => panic!("Expected System event"),
150 }
151 }
152
153 #[test]
154 fn test_parse_assistant_text() {
155 let json =
156 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"#;
157 let event = ClaudeStreamParser::parse_line(json).unwrap();
158
159 match event {
160 ClaudeStreamEvent::Assistant { message, .. } => {
161 assert_eq!(message.content.len(), 1);
162 match &message.content[0] {
163 ContentBlock::Text { text } => assert_eq!(text, "Hello world"),
164 ContentBlock::ToolUse { .. } => panic!("Expected Text content"),
165 }
166 }
167 _ => panic!("Expected Assistant event"),
168 }
169 }
170
171 #[test]
172 fn test_parse_assistant_tool_use() {
173 let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"bash","input":{"command":"ls"}}]}}"#;
174 let event = ClaudeStreamParser::parse_line(json).unwrap();
175
176 match event {
177 ClaudeStreamEvent::Assistant { message, .. } => {
178 assert_eq!(message.content.len(), 1);
179 match &message.content[0] {
180 ContentBlock::ToolUse { id, name, input } => {
181 assert_eq!(id, "tool_1");
182 assert_eq!(name, "bash");
183 assert_eq!(input["command"], "ls");
184 }
185 ContentBlock::Text { .. } => panic!("Expected ToolUse content"),
186 }
187 }
188 _ => panic!("Expected Assistant event"),
189 }
190 }
191
192 #[test]
193 fn test_parse_user_tool_result() {
194 let json = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","content":"file.txt"}]}}"#;
195 let event = ClaudeStreamParser::parse_line(json).unwrap();
196
197 match event {
198 ClaudeStreamEvent::User { message } => {
199 assert_eq!(message.content.len(), 1);
200 match &message.content[0] {
201 UserContentBlock::ToolResult {
202 tool_use_id,
203 content,
204 } => {
205 assert_eq!(tool_use_id, "tool_1");
206 assert_eq!(content, "file.txt");
207 }
208 }
209 }
210 _ => panic!("Expected User event"),
211 }
212 }
213
214 #[test]
215 fn test_parse_result_event() {
216 let json = r#"{"type":"result","duration_ms":5000,"total_cost_usd":0.02,"num_turns":2,"is_error":false}"#;
217 let event = ClaudeStreamParser::parse_line(json).unwrap();
218
219 match event {
220 ClaudeStreamEvent::Result {
221 duration_ms,
222 total_cost_usd,
223 num_turns,
224 is_error,
225 } => {
226 assert_eq!(duration_ms, 5000);
227 assert!((total_cost_usd - 0.02).abs() < f64::EPSILON);
228 assert_eq!(num_turns, 2);
229 assert!(!is_error);
230 }
231 _ => panic!("Expected Result event"),
232 }
233 }
234
235 #[test]
236 fn test_parse_empty_line() {
237 assert!(ClaudeStreamParser::parse_line("").is_none());
238 assert!(ClaudeStreamParser::parse_line(" ").is_none());
239 assert!(ClaudeStreamParser::parse_line("\n").is_none());
240 }
241
242 #[test]
243 fn test_parse_malformed_json() {
244 assert!(ClaudeStreamParser::parse_line("{not valid json}").is_none());
245 assert!(ClaudeStreamParser::parse_line("plain text").is_none());
246 assert!(ClaudeStreamParser::parse_line("{\"type\":\"unknown\"}").is_none());
247 }
248
249 #[test]
250 fn test_truncate_helper() {
251 assert_eq!(truncate("short", 10), "short");
252 assert_eq!(truncate("this is a long string", 10), "this is a ...");
253 }
254
255 #[test]
256 fn test_truncate_utf8_boundary() {
257 let s = "hello→world";
262
263 let result = truncate(s, 6);
266
267 assert!(result.ends_with("..."), "Should end with ellipsis");
270 assert!(result.len() < s.len(), "Should be truncated");
271
272 for _ in result.chars() {}
274 }
275
276 #[test]
277 fn test_truncate_utf8_emoji() {
278 let s = "hi🦀bye";
282
283 let result = truncate(s, 4);
285
286 assert!(result.ends_with("..."));
288
289 for _ in result.chars() {}
291 }
292}