1use serde::{Deserialize, Serialize};
7
8pub const RECORD_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Event {
15 pub v: u32,
17 pub id: String,
19 pub ts: i64,
21 pub agent: String,
23 pub provider: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub model: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub project: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub session_id: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub turn_id: Option<String>,
33 pub role: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub input_tok: Option<u64>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub output_tok: Option<u64>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub cache_read_tok: Option<u64>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub cache_write_tok: Option<u64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub duration_ms: Option<u64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub stop_reason: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub cost_est: Option<f64>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub tool_calls: Vec<ToolCall>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub is_sidechain: Option<bool>,
57}
58
59impl Event {
60 pub fn new(id: impl Into<String>, ts: i64, agent: &str, provider: &str, role: &str) -> Self {
62 Self {
63 v: RECORD_VERSION,
64 id: id.into(),
65 ts,
66 agent: agent.to_string(),
67 provider: provider.to_string(),
68 model: None,
69 project: None,
70 session_id: None,
71 turn_id: None,
72 role: role.to_string(),
73 input_tok: None,
74 output_tok: None,
75 cache_read_tok: None,
76 cache_write_tok: None,
77 duration_ms: None,
78 stop_reason: None,
79 cost_est: None,
80 tool_calls: Vec::new(),
81 is_sidechain: None,
82 }
83 }
84
85 pub fn total_tokens(&self) -> u64 {
96 self.input_tok.unwrap_or(0)
97 + self.output_tok.unwrap_or(0)
98 + self.cache_read_tok.unwrap_or(0)
99 + self.cache_write_tok.unwrap_or(0)
100 }
101
102 pub fn has_usage(&self) -> bool {
103 self.input_tok.is_some()
104 || self.output_tok.is_some()
105 || self.cache_read_tok.is_some()
106 || self.cache_write_tok.is_some()
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ToolCall {
112 pub tool_name: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub tool_target: Option<String>,
116}
117
118impl ToolCall {
119 pub fn new(tool_name: impl Into<String>, tool_target: Option<String>) -> Self {
120 Self {
121 tool_name: tool_name.into(),
122 tool_target,
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct PromptRecord {
131 pub event_id: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub text: Option<String>,
134 pub text_hash: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct IngestCursor {
140 pub path: String,
142 pub mtime: i64,
144 pub offset: u64,
146 pub adapter: String,
148 pub ts: i64,
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn unsupported_fields_serialize_as_absent_not_zero() {
158 let event = Event::new("abc", 1_754_300_000_000, "claude-code", "anthropic", "user");
159 let json = serde_json::to_string(&event).unwrap();
160 assert!(!json.contains("duration_ms"), "{json}");
161 assert!(!json.contains("cost_est"), "{json}");
162 assert!(!json.contains("tool_calls"), "{json}");
163 assert!(json.contains("\"v\":1"));
164 }
165
166 #[test]
167 fn tolerates_unknown_fields_and_missing_optionals() {
168 let line = r#"{"v":1,"id":"x","ts":5,"agent":"claude-code","provider":"anthropic",
169 "role":"assistant","future_field":{"nested":true}}"#;
170 let event: Event = serde_json::from_str(line).unwrap();
171 assert_eq!(event.id, "x");
172 assert_eq!(event.input_tok, None);
173 assert!(event.tool_calls.is_empty());
174 }
175
176 #[test]
177 fn round_trips_a_fully_populated_event() {
178 let mut event = Event::new("id", 1, "claude-code", "anthropic", "assistant");
179 event.model = Some("claude-sonnet-4-6".into());
180 event.input_tok = Some(412);
181 event.cache_write_tok = Some(0);
182 event.cost_est = Some(0.0412);
183 event.is_sidechain = Some(true);
184 event.tool_calls = vec![ToolCall::new("Read", Some("src/lib.rs".into()))];
185 let json = serde_json::to_string(&event).unwrap();
186 assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), event);
187 assert!(json.contains("\"cache_write_tok\":0"));
189 }
190}