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    /// Total cache-write tokens, whatever their TTL.
14    pub cache_creation_input_tokens: u64,
15    /// The subset of the above written with a 1-hour TTL, which bills at 2x
16    /// base input instead of 1.25x. Zero when the transcript carries no
17    /// `cache_creation` breakdown, which prices everything at the 5-minute
18    /// rate — the documented default.
19    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    /// The API message id. One assistant turn is written as one JSONL line per
34    /// content block, every line repeating the same `usage`, so this is what
35    /// tells those lines apart from genuinely separate turns.
36    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        // Fixture lives at the workspace root, not in this crate.
202        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}