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 {
33 message: UserMessage,
34 },
35
36 Result {
38 duration_ms: u64,
39 total_cost_usd: f64,
40 num_turns: u32,
41 is_error: bool,
42 },
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AssistantMessage {
48 pub content: Vec<ContentBlock>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct UserMessage {
54 pub content: Vec<UserContentBlock>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59#[serde(tag = "type", rename_all = "snake_case")]
60pub enum ContentBlock {
61 Text { text: String },
63 ToolUse {
65 id: String,
66 name: String,
67 input: serde_json::Value,
68 },
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum UserContentBlock {
75 ToolResult {
77 tool_use_id: String,
78 content: String,
79 },
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct Usage {
85 pub input_tokens: u64,
86 pub output_tokens: u64,
87}
88
89pub struct ClaudeStreamParser;
91
92impl ClaudeStreamParser {
93 pub fn parse_line(line: &str) -> Option<ClaudeStreamEvent> {
97 let trimmed = line.trim();
98 if trimmed.is_empty() {
99 return None;
100 }
101
102 match serde_json::from_str::<ClaudeStreamEvent>(trimmed) {
103 Ok(event) => Some(event),
104 Err(e) => {
105 tracing::debug!(
106 "Skipping malformed JSON line: {} (error: {})",
107 truncate(trimmed, 100),
108 e
109 );
110 None
111 }
112 }
113 }
114}
115
116fn truncate(s: &str, max_len: usize) -> String {
118 if s.len() <= max_len {
119 s.to_string()
120 } else {
121 format!("{}...", &s[..max_len])
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn test_parse_system_event() {
131 let json = r#"{"type":"system","session_id":"abc123","model":"claude-opus","tools":[]}"#;
132 let event = ClaudeStreamParser::parse_line(json).unwrap();
133
134 match event {
135 ClaudeStreamEvent::System { session_id, model, tools } => {
136 assert_eq!(session_id, "abc123");
137 assert_eq!(model, "claude-opus");
138 assert!(tools.is_empty());
139 }
140 _ => panic!("Expected System event"),
141 }
142 }
143
144 #[test]
145 fn test_parse_assistant_text() {
146 let json = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}"#;
147 let event = ClaudeStreamParser::parse_line(json).unwrap();
148
149 match event {
150 ClaudeStreamEvent::Assistant { message, .. } => {
151 assert_eq!(message.content.len(), 1);
152 match &message.content[0] {
153 ContentBlock::Text { text } => assert_eq!(text, "Hello world"),
154 ContentBlock::ToolUse { .. } => panic!("Expected Text content"),
155 }
156 }
157 _ => panic!("Expected Assistant event"),
158 }
159 }
160
161 #[test]
162 fn test_parse_assistant_tool_use() {
163 let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"bash","input":{"command":"ls"}}]}}"#;
164 let event = ClaudeStreamParser::parse_line(json).unwrap();
165
166 match event {
167 ClaudeStreamEvent::Assistant { message, .. } => {
168 assert_eq!(message.content.len(), 1);
169 match &message.content[0] {
170 ContentBlock::ToolUse { id, name, input } => {
171 assert_eq!(id, "tool_1");
172 assert_eq!(name, "bash");
173 assert_eq!(input["command"], "ls");
174 }
175 ContentBlock::Text { .. } => panic!("Expected ToolUse content"),
176 }
177 }
178 _ => panic!("Expected Assistant event"),
179 }
180 }
181
182 #[test]
183 fn test_parse_user_tool_result() {
184 let json = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","content":"file.txt"}]}}"#;
185 let event = ClaudeStreamParser::parse_line(json).unwrap();
186
187 match event {
188 ClaudeStreamEvent::User { message } => {
189 assert_eq!(message.content.len(), 1);
190 match &message.content[0] {
191 UserContentBlock::ToolResult { tool_use_id, content } => {
192 assert_eq!(tool_use_id, "tool_1");
193 assert_eq!(content, "file.txt");
194 }
195 }
196 }
197 _ => panic!("Expected User event"),
198 }
199 }
200
201 #[test]
202 fn test_parse_result_event() {
203 let json = r#"{"type":"result","duration_ms":5000,"total_cost_usd":0.02,"num_turns":2,"is_error":false}"#;
204 let event = ClaudeStreamParser::parse_line(json).unwrap();
205
206 match event {
207 ClaudeStreamEvent::Result { duration_ms, total_cost_usd, num_turns, is_error } => {
208 assert_eq!(duration_ms, 5000);
209 assert!((total_cost_usd - 0.02).abs() < f64::EPSILON);
210 assert_eq!(num_turns, 2);
211 assert!(!is_error);
212 }
213 _ => panic!("Expected Result event"),
214 }
215 }
216
217 #[test]
218 fn test_parse_empty_line() {
219 assert!(ClaudeStreamParser::parse_line("").is_none());
220 assert!(ClaudeStreamParser::parse_line(" ").is_none());
221 assert!(ClaudeStreamParser::parse_line("\n").is_none());
222 }
223
224 #[test]
225 fn test_parse_malformed_json() {
226 assert!(ClaudeStreamParser::parse_line("{not valid json}").is_none());
227 assert!(ClaudeStreamParser::parse_line("plain text").is_none());
228 assert!(ClaudeStreamParser::parse_line("{\"type\":\"unknown\"}").is_none());
229 }
230
231 #[test]
232 fn test_truncate_helper() {
233 assert_eq!(truncate("short", 10), "short");
234 assert_eq!(truncate("this is a long string", 10), "this is a ...");
235 }
236}