1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum NodeType {
16 User,
17 Assistant,
18 Progress,
19 System,
20 FileHistorySnapshot,
21 QueueOperation,
22 LastPrompt,
23 PrLink,
24 #[serde(other)]
26 Unknown,
27}
28
29impl NodeType {
30 pub fn as_str(&self) -> &'static str {
32 match self {
33 NodeType::User => "user",
34 NodeType::Assistant => "assistant",
35 NodeType::Progress => "progress",
36 NodeType::System => "system",
37 NodeType::FileHistorySnapshot => "file-history-snapshot",
38 NodeType::QueueOperation => "queue-operation",
39 NodeType::LastPrompt => "last-prompt",
40 NodeType::PrLink => "pr-link",
41 NodeType::Unknown => "unknown",
42 }
43 }
44}
45
46impl std::fmt::Display for NodeType {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.write_str(self.as_str())
49 }
50}
51
52mod timestamp_format {
54 use serde::{Deserialize, Deserializer};
55
56 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
57 where
58 D: Deserializer<'de>,
59 {
60 #[derive(Deserialize)]
61 #[serde(untagged)]
62 enum TimestampFormat {
63 Number(i64),
64 String(String),
65 }
66
67 match Option::<TimestampFormat>::deserialize(deserializer)? {
68 None => Ok(None),
69 Some(TimestampFormat::Number(n)) => Ok(Some(n)),
70 Some(TimestampFormat::String(s)) => {
71 chrono::DateTime::parse_from_rfc3339(&s)
73 .map(|dt| Some(dt.timestamp_millis()))
74 .map_err(serde::de::Error::custom)
75 }
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "snake_case")]
85pub enum ContentBlock {
86 Text {
87 text: String,
88 },
89 Thinking {
90 thinking: String,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 signature: Option<String>,
93 },
94 ToolUse {
95 id: String,
96 name: String,
97 input: serde_json::Value,
98 },
99 ToolResult {
100 tool_use_id: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 content: Option<serde_json::Value>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 is_error: Option<bool>,
105 },
106 #[serde(untagged)]
107 Unknown(serde_json::Value),
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum MessageContent {
114 Text(String),
115 Blocks(Vec<ContentBlock>),
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ExecutionNode {
124 pub uuid: Option<String>,
126
127 #[serde(rename = "parentUuid")]
130 pub parent_uuid: Option<String>,
131
132 #[serde(default, deserialize_with = "timestamp_format::deserialize")]
134 pub timestamp: Option<i64>,
135
136 #[serde(rename = "type")]
138 pub node_type: NodeType,
139
140 #[serde(rename = "isSidechain", default, skip_serializing_if = "Option::is_none")]
142 pub is_sidechain: Option<bool>,
143
144 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
146 pub session_id: Option<String>,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub cwd: Option<String>,
151
152 pub message: Option<Message>,
154
155 pub tool_use: Option<ToolUse>,
157
158 pub tool_result: Option<ToolResult>,
160
161 #[serde(rename = "toolUseResult")]
163 pub tool_use_result: Option<serde_json::Value>,
164
165 pub thinking: Option<String>,
167
168 pub progress: Option<Progress>,
170
171 pub token_usage: Option<TokenUsage>,
173
174 #[serde(flatten)]
176 pub extra: Option<HashMap<String, serde_json::Value>>,
177}
178
179impl ExecutionNode {
180 pub fn effective_token_usage(&self) -> Option<&TokenUsage> {
185 self.token_usage
186 .as_ref()
187 .or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
188 }
189
190 pub fn has_error(&self) -> bool {
198 let tr = self.tool_result.as_ref();
199 let tool_result_error = tr.and_then(|r| r.is_error).unwrap_or(false);
200 let content_tag_error = tr
201 .and_then(|r| r.content.as_deref())
202 .map(|c| c.contains("<tool_use_error>"))
203 .unwrap_or(false);
204
205 let tool_use_result_error = self
206 .tool_use_result
207 .as_ref()
208 .and_then(|v| {
209 serde_json::from_value::<ToolResult>(v.clone())
210 .ok()
211 .and_then(|r| r.is_error)
212 })
213 .unwrap_or(false);
214
215 let block_error = self
216 .message
217 .as_ref()
218 .map(|m| {
219 m.content_blocks().iter().any(|b| match b {
220 ContentBlock::ToolResult {
221 content, is_error, ..
222 } => {
223 is_error.unwrap_or(false)
224 || content
225 .as_ref()
226 .and_then(|v| v.as_str())
227 .map(|s| s.contains("<tool_use_error>"))
228 .unwrap_or(false)
229 }
230 _ => false,
231 })
232 })
233 .unwrap_or(false);
234
235 tool_result_error || content_tag_error || tool_use_result_error || block_error
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Message {
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub id: Option<String>,
245
246 pub role: Option<String>,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub model: Option<String>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub content: Option<MessageContent>,
256
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub usage: Option<TokenUsage>,
260
261 #[serde(flatten)]
263 pub extra: HashMap<String, serde_json::Value>,
264}
265
266impl Message {
267 pub fn content_blocks(&self) -> &[ContentBlock] {
269 match &self.content {
270 Some(MessageContent::Blocks(b)) => b.as_slice(),
271 _ => &[],
272 }
273 }
274
275 pub fn text_content(&self) -> String {
277 match &self.content {
278 Some(MessageContent::Text(s)) => s.clone(),
279 Some(MessageContent::Blocks(blocks)) => blocks
280 .iter()
281 .filter_map(|b| match b {
282 ContentBlock::Text { text } => Some(text.as_str()),
283 _ => None,
284 })
285 .collect::<Vec<_>>()
286 .join("\n\n"),
287 None => String::new(),
288 }
289 }
290
291 pub fn model_short(&self) -> Option<&str> {
294 self.model.as_deref().map(strip_model_date_suffix)
295 }
296}
297
298fn strip_model_date_suffix(model: &str) -> &str {
300 if model.len() > 9 {
301 let bytes = model.as_bytes();
302 for i in (0..model.len().saturating_sub(8)).rev() {
303 if bytes[i] == b'-' {
304 let suffix = &model[i + 1..];
305 if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
306 return &model[..i];
307 }
308 }
309 }
310 }
311 model
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ToolUse {
317 pub name: String,
319
320 pub input: serde_json::Value,
322
323 pub id: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct FileInfo {
330 #[serde(rename = "filePath")]
331 pub file_path: Option<String>,
332
333 pub content: Option<String>,
334
335 #[serde(rename = "numLines")]
336 pub num_lines: Option<i64>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ToolResult {
342 pub tool_use_id: Option<String>,
344
345 pub content: Option<String>,
347
348 pub file: Option<FileInfo>,
350
351 pub is_error: Option<bool>,
353
354 pub error: Option<String>,
356
357 pub duration_ms: Option<i64>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct Progress {
364 pub message: Option<String>,
366
367 pub percentage: Option<f64>,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, Default)]
373pub struct TokenUsage {
374 pub input_tokens: Option<i64>,
376
377 pub output_tokens: Option<i64>,
379
380 pub cache_creation_input_tokens: Option<i64>,
382
383 pub cache_read_input_tokens: Option<i64>,
385}
386
387impl TokenUsage {
388 pub fn total_input(&self) -> i64 {
391 self.input_tokens.unwrap_or(0)
392 + self.cache_creation_input_tokens.unwrap_or(0)
393 + self.cache_read_input_tokens.unwrap_or(0)
394 }
395
396 pub fn total_output(&self) -> i64 {
397 self.output_tokens.unwrap_or(0)
398 }
399
400 pub fn total(&self) -> i64 {
401 self.total_input() + self.total_output()
402 }
403
404 pub fn merge_last(&mut self, other: &TokenUsage) {
406 if other.input_tokens.is_some() {
407 self.input_tokens = other.input_tokens;
408 }
409 if other.output_tokens.is_some() {
410 self.output_tokens = other.output_tokens;
411 }
412 if other.cache_creation_input_tokens.is_some() {
413 self.cache_creation_input_tokens = other.cache_creation_input_tokens;
414 }
415 if other.cache_read_input_tokens.is_some() {
416 self.cache_read_input_tokens = other.cache_read_input_tokens;
417 }
418 }
419}
420
421#[allow(dead_code)]
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ToolUseResult {
425 #[serde(rename = "type")]
427 pub operation_type: Option<String>,
428
429 pub file_path: Option<String>,
431
432 pub content: Option<String>,
434
435 pub structured_patch: Option<serde_json::Value>,
437}
438
439#[allow(dead_code)]
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ProgressData {
443 #[serde(rename = "type")]
445 pub progress_type: Option<String>,
446
447 pub elapsed_time_seconds: Option<f64>,
449
450 pub full_output: Option<String>,
452
453 pub exit_code: Option<i32>,
455
456 pub hook_name: Option<String>,
458
459 pub status: Option<String>,
461
462 pub task_description: Option<String>,
464
465 pub task_id: Option<String>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct Session {
472 pub session_id: String,
474
475 pub file_path: Option<String>,
477
478 pub nodes: Vec<ExecutionNode>,
480
481 pub start_time: Option<i64>,
483
484 pub end_time: Option<i64>,
486
487 pub total_tools: usize,
489
490 pub error_count: usize,
492
493 pub model: Option<String>,
495}
496
497impl Session {
498 pub fn new(session_id: String, file_path: Option<String>, nodes: Vec<ExecutionNode>) -> Self {
500 let total_tools = nodes
504 .iter()
505 .flat_map(|n| {
506 n.message
507 .as_ref()
508 .map(|m| m.content_blocks())
509 .unwrap_or(&[])
510 })
511 .filter(|b| matches!(b, ContentBlock::ToolUse { .. }))
512 .count();
513
514 let error_count = nodes.iter().filter(|n| n.has_error()).count();
515
516 let start_time = nodes.iter().filter_map(|n| n.timestamp).min();
517 let end_time = nodes.iter().filter_map(|n| n.timestamp).max();
518
519 let model: Option<String> = nodes
521 .iter()
522 .filter_map(|n| n.message.as_ref())
523 .filter_map(|m| m.model_short())
524 .next()
525 .map(str::to_string);
526
527 Session {
528 session_id,
529 file_path,
530 nodes,
531 start_time,
532 end_time,
533 total_tools,
534 error_count,
535 model,
536 }
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
547 fn test_content_block_text_roundtrip() {
548 let json = r#"{"type":"text","text":"hello world"}"#;
549 let block: ContentBlock = serde_json::from_str(json).unwrap();
550 assert!(matches!(block, ContentBlock::Text { ref text } if text == "hello world"));
551 let back = serde_json::to_string(&block).unwrap();
552 assert!(back.contains("hello world"));
553 }
554
555 #[test]
556 fn test_content_block_thinking_roundtrip() {
557 let json = r#"{"type":"thinking","thinking":"deep thoughts"}"#;
558 let block: ContentBlock = serde_json::from_str(json).unwrap();
559 assert!(
560 matches!(block, ContentBlock::Thinking { thinking, .. } if thinking == "deep thoughts")
561 );
562 }
563
564 #[test]
565 fn test_content_block_tool_use_roundtrip() {
566 let json =
567 r#"{"type":"tool_use","id":"tu_123","name":"Read","input":{"file_path":"test.rs"}}"#;
568 let block: ContentBlock = serde_json::from_str(json).unwrap();
569 assert!(matches!(block, ContentBlock::ToolUse { name, .. } if name == "Read"));
570 }
571
572 #[test]
573 fn test_content_block_tool_result_roundtrip() {
574 let json = r#"{"type":"tool_result","tool_use_id":"tu_123","content":"result text","is_error":false}"#;
575 let block: ContentBlock = serde_json::from_str(json).unwrap();
576 assert!(
577 matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tu_123")
578 );
579 }
580
581 #[test]
582 fn test_content_block_unknown_falls_through_to_value() {
583 let json = r#"{"type":"future_type","data":"something"}"#;
584 let block: ContentBlock = serde_json::from_str(json).unwrap();
585 assert!(matches!(block, ContentBlock::Unknown(_)));
586 }
587
588 #[test]
591 fn test_message_content_legacy_string_deserializes() {
592 let json = r#""hello""#;
593 let mc: MessageContent = serde_json::from_str(json).unwrap();
594 assert!(matches!(mc, MessageContent::Text(_)));
595 }
596
597 #[test]
598 fn test_message_content_block_array_deserializes() {
599 let json = r#"[{"type":"text","text":"hi"}]"#;
600 let mc: MessageContent = serde_json::from_str(json).unwrap();
601 assert!(matches!(mc, MessageContent::Blocks(_)));
602 }
603
604 fn make_message_with_content(content: MessageContent) -> Message {
607 Message {
608 id: None,
609 role: Some("assistant".to_string()),
610 model: None,
611 content: Some(content),
612 usage: None,
613 extra: HashMap::new(),
614 }
615 }
616
617 #[test]
618 fn test_message_text_content_from_string() {
619 let msg = make_message_with_content(MessageContent::Text("hello".to_string()));
620 assert_eq!(msg.text_content(), "hello");
621 }
622
623 #[test]
624 fn test_message_text_content_from_blocks() {
625 let blocks = vec![
626 ContentBlock::Text {
627 text: "line one".to_string(),
628 },
629 ContentBlock::Thinking {
630 thinking: "hidden".to_string(),
631 signature: None,
632 },
633 ContentBlock::Text {
634 text: "line two".to_string(),
635 },
636 ];
637 let msg = make_message_with_content(MessageContent::Blocks(blocks));
638 let text = msg.text_content();
639 assert!(text.contains("line one"));
640 assert!(text.contains("line two"));
641 assert!(!text.contains("hidden"));
642 }
643
644 #[test]
645 fn test_message_content_blocks_empty_for_string() {
646 let msg = make_message_with_content(MessageContent::Text("x".to_string()));
647 assert!(msg.content_blocks().is_empty());
648 }
649
650 #[test]
653 fn test_strip_date_suffix_removes_8_digit_suffix() {
654 assert_eq!(
655 strip_model_date_suffix("claude-sonnet-4-5-20250929"),
656 "claude-sonnet-4-5"
657 );
658 assert_eq!(
659 strip_model_date_suffix("claude-opus-4-6-20260101"),
660 "claude-opus-4-6"
661 );
662 assert_eq!(
663 strip_model_date_suffix("claude-haiku-4-5-20251001"),
664 "claude-haiku-4-5"
665 );
666 }
667
668 #[test]
669 fn test_strip_date_suffix_no_change_when_no_suffix() {
670 assert_eq!(
671 strip_model_date_suffix("claude-sonnet-4-5"),
672 "claude-sonnet-4-5"
673 );
674 assert_eq!(strip_model_date_suffix("claude"), "claude");
675 assert_eq!(strip_model_date_suffix(""), "");
676 }
677
678 #[test]
681 fn test_token_usage_total_includes_cache_tokens() {
682 let tu = TokenUsage {
683 input_tokens: Some(100),
684 output_tokens: Some(50),
685 cache_creation_input_tokens: Some(200),
686 cache_read_input_tokens: Some(300),
687 };
688 assert_eq!(tu.total_input(), 600);
689 assert_eq!(tu.total_output(), 50);
690 assert_eq!(tu.total(), 650);
691 }
692
693 #[test]
694 fn test_token_usage_merge_last_replaces_non_none_fields() {
695 let mut base = TokenUsage {
696 input_tokens: Some(10),
697 output_tokens: Some(20),
698 cache_creation_input_tokens: None,
699 cache_read_input_tokens: None,
700 };
701 let other = TokenUsage {
702 input_tokens: Some(100),
703 output_tokens: Some(200),
704 cache_creation_input_tokens: Some(50),
705 cache_read_input_tokens: None,
706 };
707 base.merge_last(&other);
708 assert_eq!(base.input_tokens, Some(100));
709 assert_eq!(base.output_tokens, Some(200));
710 assert_eq!(base.cache_creation_input_tokens, Some(50));
711 assert_eq!(base.cache_read_input_tokens, None);
712 }
713
714 #[test]
715 fn test_token_usage_merge_last_preserves_none_fields() {
716 let mut base = TokenUsage {
717 input_tokens: Some(10),
718 output_tokens: Some(20),
719 cache_creation_input_tokens: Some(5),
720 cache_read_input_tokens: Some(3),
721 };
722 let other = TokenUsage {
723 input_tokens: None,
724 output_tokens: None,
725 cache_creation_input_tokens: None,
726 cache_read_input_tokens: None,
727 };
728 base.merge_last(&other);
729 assert_eq!(base.input_tokens, Some(10));
731 assert_eq!(base.output_tokens, Some(20));
732 assert_eq!(base.cache_creation_input_tokens, Some(5));
733 assert_eq!(base.cache_read_input_tokens, Some(3));
734 }
735
736}