Skip to main content

claudectl_core/
transcript.rs

1use 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        // Fixture lives at the workspace root, not in this crate.
183        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}