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