claudectl_core/
transcript.rs1use serde_json::Value;
2
3pub const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
8
9pub fn is_edit_tool(name: &str) -> bool {
11 EDIT_TOOLS.contains(&name)
12}
13
14pub fn edited_path(input: &Value) -> Option<&str> {
17 input
18 .get("file_path")
19 .or_else(|| input.get("notebook_path"))
20 .and_then(|v| v.as_str())
21}
22
23pub fn read_tail(path: &std::path::Path, cap: u64) -> std::io::Result<String> {
28 use std::io::{Read, Seek, SeekFrom};
29 let mut f = std::fs::File::open(path)?;
30 let len = f.metadata()?.len();
31 if len > cap {
32 f.seek(SeekFrom::Start(len - cap))?;
33 }
34 let mut buf = Vec::new();
35 f.take(cap).read_to_end(&mut buf)?;
36 Ok(String::from_utf8_lossy(&buf).into_owned())
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TranscriptRole {
41 Assistant,
42 User,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TranscriptUsage {
47 pub input_tokens: u64,
48 pub cache_read_input_tokens: u64,
49 pub cache_creation_input_tokens: u64,
51 pub cache_creation_1h_input_tokens: u64,
56 pub output_tokens: u64,
57}
58
59#[derive(Debug, Clone)]
60pub enum TranscriptBlock {
61 Text(String),
62 ToolUse { name: String, input: Value },
63 ToolResult { content: String, is_error: bool },
64}
65
66#[derive(Debug, Clone)]
67pub struct TranscriptMessage {
68 pub role: TranscriptRole,
69 pub id: Option<String>,
73 pub model: Option<String>,
74 pub stop_reason: Option<String>,
75 pub usage: Option<TranscriptUsage>,
76 pub content: Vec<TranscriptBlock>,
77}
78
79#[derive(Debug, Clone)]
80pub enum TranscriptEvent {
81 WaitingForTask,
82 Message(TranscriptMessage),
83}
84
85pub fn parse_line(line: &str) -> Option<TranscriptEvent> {
86 let entry: Value = serde_json::from_str(line).ok()?;
87
88 if is_waiting_for_task(&entry) {
89 return Some(TranscriptEvent::WaitingForTask);
90 }
91
92 let msg = entry.get("message")?;
93 let role = message_role(&entry, msg)?;
94
95 let content = msg
96 .get("content")
97 .and_then(|v| v.as_array())
98 .map(|blocks| blocks.iter().filter_map(parse_block).collect())
99 .unwrap_or_default();
100
101 Some(TranscriptEvent::Message(TranscriptMessage {
102 role,
103 id: msg
104 .get("id")
105 .and_then(|v| v.as_str())
106 .map(|s| s.to_string()),
107 model: msg
108 .get("model")
109 .and_then(|v| v.as_str())
110 .map(|s| s.to_string()),
111 stop_reason: msg
112 .get("stop_reason")
113 .and_then(|v| v.as_str())
114 .map(|s| s.to_string()),
115 usage: msg.get("usage").and_then(parse_usage),
116 content,
117 }))
118}
119
120fn is_waiting_for_task(entry: &Value) -> bool {
121 if entry.get("type").and_then(|v| v.as_str()) != Some("progress") {
122 return false;
123 }
124
125 match entry.get("data") {
126 Some(Value::String(s)) => s.contains("waiting_for_task"),
127 Some(Value::Object(map)) => map.values().any(|v| {
128 v.as_str()
129 .map(|s| s.contains("waiting_for_task"))
130 .unwrap_or(false)
131 }),
132 _ => false,
133 }
134}
135
136fn message_role(entry: &Value, msg: &Value) -> Option<TranscriptRole> {
137 let role = msg
138 .get("role")
139 .and_then(|v| v.as_str())
140 .or_else(|| entry.get("type").and_then(|v| v.as_str()))?;
141
142 match role {
143 "assistant" => Some(TranscriptRole::Assistant),
144 "user" => Some(TranscriptRole::User),
145 _ => None,
146 }
147}
148
149fn parse_usage(value: &Value) -> Option<TranscriptUsage> {
150 Some(TranscriptUsage {
151 input_tokens: value.get("input_tokens")?.as_u64().unwrap_or(0),
152 cache_read_input_tokens: value
153 .get("cache_read_input_tokens")
154 .and_then(|v| v.as_u64())
155 .unwrap_or(0),
156 cache_creation_input_tokens: value
157 .get("cache_creation_input_tokens")
158 .and_then(|v| v.as_u64())
159 .unwrap_or(0),
160 cache_creation_1h_input_tokens: value
161 .get("cache_creation")
162 .and_then(|v| v.get("ephemeral_1h_input_tokens"))
163 .and_then(|v| v.as_u64())
164 .unwrap_or(0),
165 output_tokens: value
166 .get("output_tokens")
167 .and_then(|v| v.as_u64())
168 .unwrap_or(0),
169 })
170}
171
172fn parse_block(block: &Value) -> Option<TranscriptBlock> {
173 match block.get("type").and_then(|v| v.as_str())? {
174 "text" => block
175 .get("text")
176 .and_then(|v| v.as_str())
177 .map(|s| TranscriptBlock::Text(s.to_string())),
178 "tool_use" => Some(TranscriptBlock::ToolUse {
179 name: block
180 .get("name")
181 .and_then(|v| v.as_str())
182 .unwrap_or("unknown")
183 .to_string(),
184 input: block.get("input").cloned().unwrap_or(Value::Null),
185 }),
186 "tool_result" => Some(TranscriptBlock::ToolResult {
187 content: block
188 .get("content")
189 .and_then(extract_text_content)
190 .unwrap_or_default(),
191 is_error: block
192 .get("is_error")
193 .and_then(|v| v.as_bool())
194 .unwrap_or(false),
195 }),
196 _ => None,
197 }
198}
199
200fn extract_text_content(value: &Value) -> Option<String> {
201 if let Some(text) = value.as_str() {
202 return Some(text.to_string());
203 }
204
205 let blocks = value.as_array()?;
206 let mut parts = Vec::new();
207 for block in blocks {
208 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
209 parts.push(text);
210 }
211 }
212 if parts.is_empty() {
213 None
214 } else {
215 Some(parts.join("\n"))
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn parse_real_fixture_line() {
225 let line = include_str!("../../../tests/fixtures/real-transcript-line.json");
226 let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
227 panic!("expected message event");
228 };
229 assert_eq!(msg.role, TranscriptRole::Assistant);
230 assert_eq!(msg.stop_reason.as_deref(), Some("tool_use"));
231 assert_eq!(msg.model.as_deref(), Some("claude-sonnet-4-6-20260401"));
232 assert_eq!(msg.content.len(), 2);
233 }
234
235 #[test]
236 fn parse_legacy_fixture_line() {
237 let line = include_str!("../../../tests/fixtures/legacy-transcript-line.json");
239 let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
240 panic!("expected message event");
241 };
242 assert_eq!(msg.role, TranscriptRole::Assistant);
243 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
244 assert!(msg.usage.is_some());
245 }
246
247 #[test]
248 fn parse_waiting_for_task_progress() {
249 let line = r#"{"type":"progress","data":"waiting_for_task"}"#;
250 assert!(matches!(
251 parse_line(line),
252 Some(TranscriptEvent::WaitingForTask)
253 ));
254 }
255}