claudectl_core/
transcript.rs1use serde_json::Value;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum TranscriptRole {
5 Assistant,
6 User,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TranscriptUsage {
11 pub input_tokens: u64,
12 pub cache_read_input_tokens: u64,
13 pub cache_creation_input_tokens: u64,
15 pub cache_creation_1h_input_tokens: u64,
20 pub output_tokens: u64,
21}
22
23#[derive(Debug, Clone)]
24pub enum TranscriptBlock {
25 Text(String),
26 ToolUse { name: String, input: Value },
27 ToolResult { content: String, is_error: bool },
28}
29
30#[derive(Debug, Clone)]
31pub struct TranscriptMessage {
32 pub role: TranscriptRole,
33 pub id: Option<String>,
37 pub model: Option<String>,
38 pub stop_reason: Option<String>,
39 pub usage: Option<TranscriptUsage>,
40 pub content: Vec<TranscriptBlock>,
41}
42
43#[derive(Debug, Clone)]
44pub enum TranscriptEvent {
45 WaitingForTask,
46 Message(TranscriptMessage),
47}
48
49pub fn parse_line(line: &str) -> Option<TranscriptEvent> {
50 let entry: Value = serde_json::from_str(line).ok()?;
51
52 if is_waiting_for_task(&entry) {
53 return Some(TranscriptEvent::WaitingForTask);
54 }
55
56 let msg = entry.get("message")?;
57 let role = message_role(&entry, msg)?;
58
59 let content = msg
60 .get("content")
61 .and_then(|v| v.as_array())
62 .map(|blocks| blocks.iter().filter_map(parse_block).collect())
63 .unwrap_or_default();
64
65 Some(TranscriptEvent::Message(TranscriptMessage {
66 role,
67 id: msg
68 .get("id")
69 .and_then(|v| v.as_str())
70 .map(|s| s.to_string()),
71 model: msg
72 .get("model")
73 .and_then(|v| v.as_str())
74 .map(|s| s.to_string()),
75 stop_reason: msg
76 .get("stop_reason")
77 .and_then(|v| v.as_str())
78 .map(|s| s.to_string()),
79 usage: msg.get("usage").and_then(parse_usage),
80 content,
81 }))
82}
83
84fn is_waiting_for_task(entry: &Value) -> bool {
85 if entry.get("type").and_then(|v| v.as_str()) != Some("progress") {
86 return false;
87 }
88
89 match entry.get("data") {
90 Some(Value::String(s)) => s.contains("waiting_for_task"),
91 Some(Value::Object(map)) => map.values().any(|v| {
92 v.as_str()
93 .map(|s| s.contains("waiting_for_task"))
94 .unwrap_or(false)
95 }),
96 _ => false,
97 }
98}
99
100fn message_role(entry: &Value, msg: &Value) -> Option<TranscriptRole> {
101 let role = msg
102 .get("role")
103 .and_then(|v| v.as_str())
104 .or_else(|| entry.get("type").and_then(|v| v.as_str()))?;
105
106 match role {
107 "assistant" => Some(TranscriptRole::Assistant),
108 "user" => Some(TranscriptRole::User),
109 _ => None,
110 }
111}
112
113fn parse_usage(value: &Value) -> Option<TranscriptUsage> {
114 Some(TranscriptUsage {
115 input_tokens: value.get("input_tokens")?.as_u64().unwrap_or(0),
116 cache_read_input_tokens: value
117 .get("cache_read_input_tokens")
118 .and_then(|v| v.as_u64())
119 .unwrap_or(0),
120 cache_creation_input_tokens: value
121 .get("cache_creation_input_tokens")
122 .and_then(|v| v.as_u64())
123 .unwrap_or(0),
124 cache_creation_1h_input_tokens: value
125 .get("cache_creation")
126 .and_then(|v| v.get("ephemeral_1h_input_tokens"))
127 .and_then(|v| v.as_u64())
128 .unwrap_or(0),
129 output_tokens: value
130 .get("output_tokens")
131 .and_then(|v| v.as_u64())
132 .unwrap_or(0),
133 })
134}
135
136fn parse_block(block: &Value) -> Option<TranscriptBlock> {
137 match block.get("type").and_then(|v| v.as_str())? {
138 "text" => block
139 .get("text")
140 .and_then(|v| v.as_str())
141 .map(|s| TranscriptBlock::Text(s.to_string())),
142 "tool_use" => Some(TranscriptBlock::ToolUse {
143 name: block
144 .get("name")
145 .and_then(|v| v.as_str())
146 .unwrap_or("unknown")
147 .to_string(),
148 input: block.get("input").cloned().unwrap_or(Value::Null),
149 }),
150 "tool_result" => Some(TranscriptBlock::ToolResult {
151 content: block
152 .get("content")
153 .and_then(extract_text_content)
154 .unwrap_or_default(),
155 is_error: block
156 .get("is_error")
157 .and_then(|v| v.as_bool())
158 .unwrap_or(false),
159 }),
160 _ => None,
161 }
162}
163
164fn extract_text_content(value: &Value) -> Option<String> {
165 if let Some(text) = value.as_str() {
166 return Some(text.to_string());
167 }
168
169 let blocks = value.as_array()?;
170 let mut parts = Vec::new();
171 for block in blocks {
172 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
173 parts.push(text);
174 }
175 }
176 if parts.is_empty() {
177 None
178 } else {
179 Some(parts.join("\n"))
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn parse_real_fixture_line() {
189 let line = include_str!("../../../tests/fixtures/real-transcript-line.json");
190 let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
191 panic!("expected message event");
192 };
193 assert_eq!(msg.role, TranscriptRole::Assistant);
194 assert_eq!(msg.stop_reason.as_deref(), Some("tool_use"));
195 assert_eq!(msg.model.as_deref(), Some("claude-sonnet-4-6-20260401"));
196 assert_eq!(msg.content.len(), 2);
197 }
198
199 #[test]
200 fn parse_legacy_fixture_line() {
201 let line = include_str!("../../../tests/fixtures/legacy-transcript-line.json");
203 let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
204 panic!("expected message event");
205 };
206 assert_eq!(msg.role, TranscriptRole::Assistant);
207 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
208 assert!(msg.usage.is_some());
209 }
210
211 #[test]
212 fn parse_waiting_for_task_progress() {
213 let line = r#"{"type":"progress","data":"waiting_for_task"}"#;
214 assert!(matches!(
215 parse_line(line),
216 Some(TranscriptEvent::WaitingForTask)
217 ));
218 }
219}