use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use super::content::Message;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommonFields {
pub uuid: Uuid,
#[serde(default)]
pub parent_uuid: Option<Uuid>,
pub timestamp: DateTime<Utc>,
pub session_id: String,
#[serde(default)]
pub is_sidechain: bool,
#[serde(default)]
pub is_meta: bool,
#[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub enum TranscriptEntry {
User(UserEntry),
Assistant(AssistantEntry),
Summary(SummaryEntry),
System(SystemEntry),
QueueOperation(QueueOperationEntry),
HookAttachment(HookAttachmentEntry),
AwaySummary(AwaySummaryEntry),
Unknown {
entry_type: String,
raw: serde_json::Value,
},
}
impl<'de> Deserialize<'de> for TranscriptEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let type_str = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
let entry = match type_str {
"user" => TranscriptEntry::User(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"assistant" => TranscriptEntry::Assistant(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"summary" => TranscriptEntry::Summary(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"system" => TranscriptEntry::System(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"queue-operation" => TranscriptEntry::QueueOperation(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"hook-attachment" | "attachment" => TranscriptEntry::HookAttachment(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
"away-summary" => TranscriptEntry::AwaySummary(
serde_json::from_value(value).map_err(serde::de::Error::custom)?,
),
other => TranscriptEntry::Unknown {
entry_type: other.to_string(),
raw: value,
},
};
Ok(entry)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub team_name: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub user_type: Option<String>,
pub message: Message,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistantEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub team_name: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
pub message: Message,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummaryEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub leaf_uuid: Option<Uuid>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub system: Option<serde_json::Value>,
#[serde(default)]
pub subtype: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub message_count: Option<u64>,
#[serde(default)]
pub hook_count: Option<u64>,
#[serde(default)]
pub hook_infos: Option<serde_json::Value>,
#[serde(default)]
pub level: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueueOperationEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub operation: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookAttachmentEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub attachment: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AwaySummaryEntry {
#[serde(flatten)]
pub common: CommonFields,
#[serde(default)]
pub summary: Option<String>,
}
impl TranscriptEntry {
pub fn common(&self) -> &CommonFields {
match self {
TranscriptEntry::User(e) => &e.common,
TranscriptEntry::Assistant(e) => &e.common,
TranscriptEntry::Summary(e) => &e.common,
TranscriptEntry::System(e) => &e.common,
TranscriptEntry::QueueOperation(e) => &e.common,
TranscriptEntry::HookAttachment(e) => &e.common,
TranscriptEntry::AwaySummary(e) => &e.common,
TranscriptEntry::Unknown { .. } => {
panic!("Unknown entry has no common fields")
}
}
}
pub fn entry_type(&self) -> &str {
match self {
TranscriptEntry::User(_) => "user",
TranscriptEntry::Assistant(_) => "assistant",
TranscriptEntry::Summary(_) => "summary",
TranscriptEntry::System(_) => "system",
TranscriptEntry::QueueOperation(_) => "queue-operation",
TranscriptEntry::HookAttachment(_) => "hook-attachment",
TranscriptEntry::AwaySummary(_) => "away-summary",
TranscriptEntry::Unknown { entry_type, .. } => entry_type.as_str(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_user_entry() {
let json = serde_json::json!({
"type": "user",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"isSidechain": false,
"agentId": null,
"cwd": "/home/user/project",
"gitBranch": "main",
"version": "1.0.0",
"teamName": "my-team",
"requestId": "req-001",
"userType": "human",
"message": {
"role": "user",
"content": [
{"type": "text", "text": "Hello, Claude!"}
]
}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::User(_)));
assert_eq!(entry.entry_type(), "user");
assert_eq!(entry.common().session_id, "session-abc123");
}
#[test]
fn round_trip_assistant_entry() {
let json = serde_json::json!({
"type": "assistant",
"uuid": "660e8400-e29b-41d4-a716-446655440000",
"parentUuid": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-06-15T10:30:05Z",
"sessionId": "session-abc123",
"isSidechain": false,
"agentId": "claude-opus-4-7",
"cwd": "/home/user/project",
"gitBranch": "main",
"version": "1.0.0",
"message": {
"role": "assistant",
"model": "claude-opus-4-7",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 150,
"output_tokens": 80
},
"content": [
{"type": "text", "text": "Hello! How can I help?"}
]
}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::Assistant(_)));
}
#[test]
fn round_trip_summary_entry() {
let json = serde_json::json!({
"type": "summary",
"uuid": "770e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T11:00:00Z",
"sessionId": "session-abc123",
"isSidechain": false,
"leafUuid": "660e8400-e29b-41d4-a716-446655440000",
"summary": "Discussed project architecture.",
"title": "Architecture Discussion"
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::Summary(_)));
}
#[test]
fn round_trip_system_entry() {
let json = serde_json::json!({
"type": "system",
"uuid": "880e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T10:29:00Z",
"sessionId": "session-abc123",
"isSidechain": false,
"system": {"tools": ["bash", "read", "write"]}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::System(_)));
}
#[test]
fn round_trip_queue_operation_entry() {
let json = serde_json::json!({
"type": "queue-operation",
"uuid": "990e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"operation": {"action": "create", "taskId": "task-1"}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::QueueOperation(_)));
}
#[test]
fn round_trip_hook_attachment_entry() {
let json = serde_json::json!({
"type": "hook-attachment",
"uuid": "aa0e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"attachment": {"hook": "pre-commit", "data": {}}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::HookAttachment(_)));
}
#[test]
fn round_trip_away_summary_entry() {
let json = serde_json::json!({
"type": "away-summary",
"uuid": "bb0e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T12:00:00Z",
"sessionId": "session-abc123",
"summary": "User was away for 30 minutes."
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::AwaySummary(_)));
}
#[test]
fn unknown_type_falls_through_to_unknown_variant() {
let json = serde_json::json!({
"type": "future-feature",
"uuid": "cc0e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"customField": 42
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
match entry {
TranscriptEntry::Unknown { entry_type, raw } => {
assert_eq!(entry_type, "future-feature");
assert_eq!(raw["customField"], 42);
}
other => panic!("expected Unknown variant, got {:?}", other),
}
}
#[test]
fn missing_optional_fields_default_correctly() {
let json = serde_json::json!({
"type": "user",
"uuid": "dd0e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"message": {
"role": "user",
"content": [
{"type": "text", "text": "minimal message"}
]
}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
match entry {
TranscriptEntry::User(u) => {
assert!(u.common.parent_uuid.is_none());
assert!(!u.common.is_sidechain);
assert!(u.common.agent_id.is_none());
assert!(u.common.cwd.is_none());
}
other => panic!("expected User variant, got {:?}", other),
}
}
#[test]
fn attachment_variant_deserializes_as_hook_attachment() {
let json = serde_json::json!({
"type": "attachment",
"uuid": "ee0e8400-e29b-41d4-a716-446655440000",
"parentUuid": null,
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"attachment": {"key": "value"}
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
assert!(matches!(entry, TranscriptEntry::HookAttachment(_)));
}
#[test]
fn common_fields_deserialize_all_optional() {
let json = serde_json::json!({
"uuid": "ff0e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"isSidechain": true,
"isMeta": true,
"agentId": "claude-opus-4-7",
"cwd": "/home/user",
"gitBranch": "feature/x",
"version": "2.0.0"
});
let cf: CommonFields = serde_json::from_value(json).unwrap();
assert_eq!(cf.uuid.to_string(), "ff0e8400-e29b-41d4-a716-446655440000");
assert!(cf.is_sidechain);
assert!(cf.is_meta);
assert_eq!(cf.agent_id.as_deref(), Some("claude-opus-4-7"));
assert_eq!(cf.cwd.as_deref(), Some("/home/user"));
assert_eq!(cf.git_branch.as_deref(), Some("feature/x"));
assert_eq!(cf.version.as_deref(), Some("2.0.0"));
}
#[test]
fn system_entry_with_subtype_turn_duration() {
let json = serde_json::json!({
"type": "system",
"uuid": "11111111-1111-1111-1111-111111111111",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"subtype": "turn_duration",
"durationMs": 1500,
"messageCount": 3
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
match entry {
TranscriptEntry::System(s) => {
assert_eq!(s.subtype.as_deref(), Some("turn_duration"));
assert_eq!(s.duration_ms, Some(1500));
assert_eq!(s.message_count, Some(3));
}
other => panic!("expected System variant, got {:?}", other),
}
}
#[test]
fn system_entry_with_stop_hook_summary() {
let json = serde_json::json!({
"type": "system",
"uuid": "22222222-2222-2222-2222-222222222222",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"subtype": "stop_hook_summary",
"hookCount": 5,
"hookInfos": [{"name": "pre-commit"}],
"level": "warning"
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
match entry {
TranscriptEntry::System(s) => {
assert_eq!(s.subtype.as_deref(), Some("stop_hook_summary"));
assert_eq!(s.hook_count, Some(5));
assert_eq!(s.level.as_deref(), Some("warning"));
}
other => panic!("expected System variant, got {:?}", other),
}
}
#[test]
fn system_entry_with_content_field() {
let json = serde_json::json!({
"type": "system",
"uuid": "33333333-3333-3333-3333-333333333333",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "session-abc123",
"subtype": "away_summary",
"content": "User stepped away for 10 minutes."
});
let entry: TranscriptEntry = serde_json::from_value(json).unwrap();
match entry {
TranscriptEntry::System(s) => {
assert_eq!(s.content.as_deref(), Some("User stepped away for 10 minutes."));
}
other => panic!("expected System variant, got {:?}", other),
}
}
#[test]
fn all_entry_types_have_common_fields() {
let base = |t: &str| -> serde_json::Value {
serde_json::json!({
"type": t,
"uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"timestamp": "2025-06-15T10:30:00Z",
"sessionId": "s1"
})
};
let cases: Vec<(&str, serde_json::Value)> = vec![
("user", {
let mut v = base("user");
v["message"] = serde_json::json!({"role": "user", "content": []});
v
}),
("assistant", {
let mut v = base("assistant");
v["message"] = serde_json::json!({"role": "assistant", "content": []});
v
}),
("summary", base("summary")),
("system", base("system")),
("queue-operation", base("queue-operation")),
("hook-attachment", base("hook-attachment")),
("attachment", base("attachment")),
("away-summary", base("away-summary")),
];
for (type_str, json) in &cases {
let entry: TranscriptEntry = serde_json::from_value(json.clone())
.unwrap_or_else(|e| panic!("failed to deserialize '{}' entry: {}", type_str, e));
assert_eq!(
entry.entry_type(),
match *type_str {
"attachment" => "hook-attachment",
other => other,
},
"entry_type() mismatch for type='{}'",
type_str
);
}
}
}