use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NodeType {
User,
Assistant,
Progress,
System,
FileHistorySnapshot,
QueueOperation,
LastPrompt,
PrLink,
#[serde(other)]
Unknown,
}
impl NodeType {
pub fn as_str(&self) -> &'static str {
match self {
NodeType::User => "user",
NodeType::Assistant => "assistant",
NodeType::Progress => "progress",
NodeType::System => "system",
NodeType::FileHistorySnapshot => "file-history-snapshot",
NodeType::QueueOperation => "queue-operation",
NodeType::LastPrompt => "last-prompt",
NodeType::PrLink => "pr-link",
NodeType::Unknown => "unknown",
}
}
}
impl std::fmt::Display for NodeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
mod timestamp_format {
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum TimestampFormat {
Number(i64),
String(String),
}
match Option::<TimestampFormat>::deserialize(deserializer)? {
None => Ok(None),
Some(TimestampFormat::Number(n)) => Ok(Some(n)),
Some(TimestampFormat::String(s)) => {
chrono::DateTime::parse_from_rfc3339(&s)
.map(|dt| Some(dt.timestamp_millis()))
.map_err(serde::de::Error::custom)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
Thinking {
thinking: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
#[serde(untagged)]
Unknown(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionNode {
pub uuid: Option<String>,
#[serde(rename = "parentUuid")]
pub parent_uuid: Option<String>,
#[serde(default, deserialize_with = "timestamp_format::deserialize")]
pub timestamp: Option<i64>,
#[serde(rename = "type")]
pub node_type: NodeType,
#[serde(rename = "isSidechain", default, skip_serializing_if = "Option::is_none")]
pub is_sidechain: Option<bool>,
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
pub message: Option<Message>,
pub tool_use: Option<ToolUse>,
pub tool_result: Option<ToolResult>,
#[serde(rename = "toolUseResult")]
pub tool_use_result: Option<serde_json::Value>,
pub thinking: Option<String>,
pub progress: Option<Progress>,
pub token_usage: Option<TokenUsage>,
#[serde(flatten)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
impl ExecutionNode {
pub fn effective_token_usage(&self) -> Option<&TokenUsage> {
self.token_usage
.as_ref()
.or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
}
pub fn has_error(&self) -> bool {
let tr = self.tool_result.as_ref();
let tool_result_error = tr.and_then(|r| r.is_error).unwrap_or(false);
let content_tag_error = tr
.and_then(|r| r.content.as_deref())
.map(|c| c.contains("<tool_use_error>"))
.unwrap_or(false);
let tool_use_result_error = self
.tool_use_result
.as_ref()
.and_then(|v| {
serde_json::from_value::<ToolResult>(v.clone())
.ok()
.and_then(|r| r.is_error)
})
.unwrap_or(false);
let block_error = self
.message
.as_ref()
.map(|m| {
m.content_blocks().iter().any(|b| match b {
ContentBlock::ToolResult {
content, is_error, ..
} => {
is_error.unwrap_or(false)
|| content
.as_ref()
.and_then(|v| v.as_str())
.map(|s| s.contains("<tool_use_error>"))
.unwrap_or(false)
}
_ => false,
})
})
.unwrap_or(false);
tool_result_error || content_tag_error || tool_use_result_error || block_error
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<MessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Message {
pub fn content_blocks(&self) -> &[ContentBlock] {
match &self.content {
Some(MessageContent::Blocks(b)) => b.as_slice(),
_ => &[],
}
}
pub fn text_content(&self) -> String {
match &self.content {
Some(MessageContent::Text(s)) => s.clone(),
Some(MessageContent::Blocks(blocks)) => blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n"),
None => String::new(),
}
}
pub fn model_short(&self) -> Option<&str> {
self.model.as_deref().map(strip_model_date_suffix)
}
}
fn strip_model_date_suffix(model: &str) -> &str {
if model.len() > 9 {
let bytes = model.as_bytes();
for i in (0..model.len().saturating_sub(8)).rev() {
if bytes[i] == b'-' {
let suffix = &model[i + 1..];
if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
return &model[..i];
}
}
}
}
model
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUse {
pub name: String,
pub input: serde_json::Value,
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
#[serde(rename = "filePath")]
pub file_path: Option<String>,
pub content: Option<String>,
#[serde(rename = "numLines")]
pub num_lines: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_use_id: Option<String>,
pub content: Option<String>,
pub file: Option<FileInfo>,
pub is_error: Option<bool>,
pub error: Option<String>,
pub duration_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Progress {
pub message: Option<String>,
pub percentage: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage {
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub cache_creation_input_tokens: Option<i64>,
pub cache_read_input_tokens: Option<i64>,
}
impl TokenUsage {
pub fn total_input(&self) -> i64 {
self.input_tokens.unwrap_or(0)
+ self.cache_creation_input_tokens.unwrap_or(0)
+ self.cache_read_input_tokens.unwrap_or(0)
}
pub fn total_output(&self) -> i64 {
self.output_tokens.unwrap_or(0)
}
pub fn total(&self) -> i64 {
self.total_input() + self.total_output()
}
pub fn merge_last(&mut self, other: &TokenUsage) {
if other.input_tokens.is_some() {
self.input_tokens = other.input_tokens;
}
if other.output_tokens.is_some() {
self.output_tokens = other.output_tokens;
}
if other.cache_creation_input_tokens.is_some() {
self.cache_creation_input_tokens = other.cache_creation_input_tokens;
}
if other.cache_read_input_tokens.is_some() {
self.cache_read_input_tokens = other.cache_read_input_tokens;
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUseResult {
#[serde(rename = "type")]
pub operation_type: Option<String>,
pub file_path: Option<String>,
pub content: Option<String>,
pub structured_patch: Option<serde_json::Value>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressData {
#[serde(rename = "type")]
pub progress_type: Option<String>,
pub elapsed_time_seconds: Option<f64>,
pub full_output: Option<String>,
pub exit_code: Option<i32>,
pub hook_name: Option<String>,
pub status: Option<String>,
pub task_description: Option<String>,
pub task_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub file_path: Option<String>,
pub nodes: Vec<ExecutionNode>,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub total_tools: usize,
pub error_count: usize,
pub model: Option<String>,
}
impl Session {
pub fn new(session_id: String, file_path: Option<String>, nodes: Vec<ExecutionNode>) -> Self {
let total_tools = nodes
.iter()
.flat_map(|n| {
n.message
.as_ref()
.map(|m| m.content_blocks())
.unwrap_or(&[])
})
.filter(|b| matches!(b, ContentBlock::ToolUse { .. }))
.count();
let error_count = nodes.iter().filter(|n| n.has_error()).count();
let start_time = nodes.iter().filter_map(|n| n.timestamp).min();
let end_time = nodes.iter().filter_map(|n| n.timestamp).max();
let model: Option<String> = nodes
.iter()
.filter_map(|n| n.message.as_ref())
.filter_map(|m| m.model_short())
.next()
.map(str::to_string);
Session {
session_id,
file_path,
nodes,
start_time,
end_time,
total_tools,
error_count,
model,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_block_text_roundtrip() {
let json = r#"{"type":"text","text":"hello world"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Text { ref text } if text == "hello world"));
let back = serde_json::to_string(&block).unwrap();
assert!(back.contains("hello world"));
}
#[test]
fn test_content_block_thinking_roundtrip() {
let json = r#"{"type":"thinking","thinking":"deep thoughts"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(
matches!(block, ContentBlock::Thinking { thinking, .. } if thinking == "deep thoughts")
);
}
#[test]
fn test_content_block_tool_use_roundtrip() {
let json =
r#"{"type":"tool_use","id":"tu_123","name":"Read","input":{"file_path":"test.rs"}}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::ToolUse { name, .. } if name == "Read"));
}
#[test]
fn test_content_block_tool_result_roundtrip() {
let json = r#"{"type":"tool_result","tool_use_id":"tu_123","content":"result text","is_error":false}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(
matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "tu_123")
);
}
#[test]
fn test_content_block_unknown_falls_through_to_value() {
let json = r#"{"type":"future_type","data":"something"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Unknown(_)));
}
#[test]
fn test_message_content_legacy_string_deserializes() {
let json = r#""hello""#;
let mc: MessageContent = serde_json::from_str(json).unwrap();
assert!(matches!(mc, MessageContent::Text(_)));
}
#[test]
fn test_message_content_block_array_deserializes() {
let json = r#"[{"type":"text","text":"hi"}]"#;
let mc: MessageContent = serde_json::from_str(json).unwrap();
assert!(matches!(mc, MessageContent::Blocks(_)));
}
fn make_message_with_content(content: MessageContent) -> Message {
Message {
id: None,
role: Some("assistant".to_string()),
model: None,
content: Some(content),
usage: None,
extra: HashMap::new(),
}
}
#[test]
fn test_message_text_content_from_string() {
let msg = make_message_with_content(MessageContent::Text("hello".to_string()));
assert_eq!(msg.text_content(), "hello");
}
#[test]
fn test_message_text_content_from_blocks() {
let blocks = vec![
ContentBlock::Text {
text: "line one".to_string(),
},
ContentBlock::Thinking {
thinking: "hidden".to_string(),
signature: None,
},
ContentBlock::Text {
text: "line two".to_string(),
},
];
let msg = make_message_with_content(MessageContent::Blocks(blocks));
let text = msg.text_content();
assert!(text.contains("line one"));
assert!(text.contains("line two"));
assert!(!text.contains("hidden"));
}
#[test]
fn test_message_content_blocks_empty_for_string() {
let msg = make_message_with_content(MessageContent::Text("x".to_string()));
assert!(msg.content_blocks().is_empty());
}
#[test]
fn test_strip_date_suffix_removes_8_digit_suffix() {
assert_eq!(
strip_model_date_suffix("claude-sonnet-4-5-20250929"),
"claude-sonnet-4-5"
);
assert_eq!(
strip_model_date_suffix("claude-opus-4-6-20260101"),
"claude-opus-4-6"
);
assert_eq!(
strip_model_date_suffix("claude-haiku-4-5-20251001"),
"claude-haiku-4-5"
);
}
#[test]
fn test_strip_date_suffix_no_change_when_no_suffix() {
assert_eq!(
strip_model_date_suffix("claude-sonnet-4-5"),
"claude-sonnet-4-5"
);
assert_eq!(strip_model_date_suffix("claude"), "claude");
assert_eq!(strip_model_date_suffix(""), "");
}
#[test]
fn test_token_usage_total_includes_cache_tokens() {
let tu = TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_creation_input_tokens: Some(200),
cache_read_input_tokens: Some(300),
};
assert_eq!(tu.total_input(), 600);
assert_eq!(tu.total_output(), 50);
assert_eq!(tu.total(), 650);
}
#[test]
fn test_token_usage_merge_last_replaces_non_none_fields() {
let mut base = TokenUsage {
input_tokens: Some(10),
output_tokens: Some(20),
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
};
let other = TokenUsage {
input_tokens: Some(100),
output_tokens: Some(200),
cache_creation_input_tokens: Some(50),
cache_read_input_tokens: None,
};
base.merge_last(&other);
assert_eq!(base.input_tokens, Some(100));
assert_eq!(base.output_tokens, Some(200));
assert_eq!(base.cache_creation_input_tokens, Some(50));
assert_eq!(base.cache_read_input_tokens, None);
}
#[test]
fn test_token_usage_merge_last_preserves_none_fields() {
let mut base = TokenUsage {
input_tokens: Some(10),
output_tokens: Some(20),
cache_creation_input_tokens: Some(5),
cache_read_input_tokens: Some(3),
};
let other = TokenUsage {
input_tokens: None,
output_tokens: None,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
};
base.merge_last(&other);
assert_eq!(base.input_tokens, Some(10));
assert_eq!(base.output_tokens, Some(20));
assert_eq!(base.cache_creation_input_tokens, Some(5));
assert_eq!(base.cache_read_input_tokens, Some(3));
}
}