1use chrono::{DateTime, Utc};
2use serde::Deserialize;
3use std::path::PathBuf;
4
5#[derive(Debug, Deserialize)]
9#[serde(tag = "type")]
10#[allow(clippy::large_enum_variant)]
11pub enum JournalEntry {
12 #[serde(rename = "user")]
13 User(UserMessage),
14 #[serde(rename = "assistant")]
15 Assistant(AssistantMessage),
16 #[serde(rename = "queue-operation")]
17 QueueOperation(serde_json::Value),
18}
19
20#[derive(Debug, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct UserMessage {
24 pub uuid: Option<String>,
25 pub session_id: Option<String>,
26 pub timestamp: Option<String>,
27 pub cwd: Option<String>,
28 pub version: Option<String>,
29 pub git_branch: Option<String>,
30 pub message: Option<serde_json::Value>,
31 pub parent_uuid: Option<String>,
32 pub is_sidechain: Option<bool>,
33 pub user_type: Option<String>,
34}
35
36#[derive(Debug, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct AssistantMessage {
40 pub uuid: Option<String>,
41 pub session_id: Option<String>,
42 pub timestamp: Option<String>,
43 pub request_id: Option<String>,
44 pub agent_id: Option<String>,
45 pub message: Option<ApiMessage>,
46 pub cwd: Option<String>,
47 pub version: Option<String>,
48 pub git_branch: Option<String>,
49 pub parent_uuid: Option<String>,
50 pub is_sidechain: Option<bool>,
51 pub user_type: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
56pub struct ApiMessage {
57 pub model: Option<String>,
58 pub role: Option<String>,
59 pub stop_reason: Option<String>,
60 pub usage: Option<TokenUsage>,
61 pub content: Option<Vec<ContentBlock>>,
62}
63
64#[derive(Debug, Clone, Deserialize)]
66pub struct TokenUsage {
67 pub input_tokens: Option<u64>,
68 pub output_tokens: Option<u64>,
69 pub cache_creation_input_tokens: Option<u64>,
70 pub cache_read_input_tokens: Option<u64>,
71 pub cache_creation: Option<CacheCreationDetail>,
72 pub server_tool_use: Option<ServerToolUse>,
73 pub service_tier: Option<String>,
74 pub speed: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Deserialize)]
79pub struct CacheCreationDetail {
80 pub ephemeral_5m_input_tokens: Option<u64>,
81 pub ephemeral_1h_input_tokens: Option<u64>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
86pub struct ServerToolUse {
87 pub web_search_requests: Option<u64>,
88 pub web_fetch_requests: Option<u64>,
89}
90
91#[derive(Debug, Deserialize)]
94#[serde(tag = "type")]
95pub enum ContentBlock {
96 #[serde(rename = "text")]
97 Text {
98 text: Option<String>,
99 },
100 #[serde(rename = "tool_use")]
101 ToolUse {
102 id: Option<String>,
103 name: Option<String>,
104 input: Option<serde_json::Value>,
105 },
106 #[serde(other)]
107 Other,
108}
109
110#[derive(Debug)]
114pub struct ValidatedTurn {
115 pub uuid: String,
116 pub request_id: Option<String>,
117 pub timestamp: DateTime<Utc>,
118 pub model: String,
119 pub usage: TokenUsage,
120 pub stop_reason: Option<String>,
121 pub content_types: Vec<String>,
122 pub is_agent: bool,
123 pub agent_id: Option<String>,
124 pub user_text: Option<String>, pub assistant_text: Option<String>, pub tool_names: Vec<String>, }
128
129#[derive(Debug)]
131pub struct SessionFile {
132 pub session_id: String,
133 pub project: Option<String>,
134 pub file_path: PathBuf,
135 pub is_agent: bool,
136 pub parent_session_id: Option<String>,
137}
138
139#[derive(Debug)]
141pub struct SessionData {
142 pub session_id: String,
143 pub project: Option<String>,
144 pub turns: Vec<ValidatedTurn>,
145 pub agent_turns: Vec<ValidatedTurn>,
146 pub first_timestamp: Option<DateTime<Utc>>,
147 pub last_timestamp: Option<DateTime<Utc>>,
148 pub version: Option<String>,
149 pub quality: DataQuality,
150}
151
152#[derive(Debug, Default)]
154pub struct DataQuality {
155 pub total_lines: usize,
156 pub valid_turns: usize,
157 pub skipped_synthetic: usize,
158 pub skipped_invalid: usize,
159 pub skipped_parse_error: usize,
160 pub duplicate_turns: usize,
161}
162
163#[derive(Debug, Default, Clone)]
165pub struct GlobalDataQuality {
166 pub total_session_files: usize,
167 pub total_agent_files: usize,
168 pub orphan_agents: usize,
169 pub total_valid_turns: usize,
170 pub total_skipped: usize,
171 pub time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
172}
173
174#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_parse_assistant_message() {
182 let json = r#"{"parentUuid":"abc","isSidechain":false,"type":"assistant","uuid":"def","timestamp":"2026-03-16T13:51:35.912Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":3,"cache_creation_input_tokens":1281,"cache_read_input_tokens":15204,"cache_creation":{"ephemeral_5m_input_tokens":1281,"ephemeral_1h_input_tokens":0},"output_tokens":108,"service_tier":"standard"},"content":[{"type":"text","text":"Hello"}]},"sessionId":"abc-123","version":"2.0.77","cwd":"/tmp","gitBranch":"main","userType":"external","requestId":"req_1"}"#;
183
184 let entry: JournalEntry = serde_json::from_str(json).unwrap();
185
186 match entry {
187 JournalEntry::Assistant(msg) => {
188 assert_eq!(msg.uuid.as_deref(), Some("def"));
189 assert_eq!(msg.session_id.as_deref(), Some("abc-123"));
190 assert_eq!(msg.request_id.as_deref(), Some("req_1"));
191 assert_eq!(msg.parent_uuid.as_deref(), Some("abc"));
192 assert_eq!(msg.is_sidechain, Some(false));
193
194 let api = msg.message.unwrap();
195 assert_eq!(api.model.as_deref(), Some("claude-opus-4-6"));
196 assert_eq!(api.stop_reason.as_deref(), Some("end_turn"));
197
198 let usage = api.usage.unwrap();
199 assert_eq!(usage.input_tokens, Some(3));
200 assert_eq!(usage.output_tokens, Some(108));
201 assert_eq!(usage.cache_creation_input_tokens, Some(1281));
202 assert_eq!(usage.cache_read_input_tokens, Some(15204));
203 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
204
205 let cache = usage.cache_creation.unwrap();
206 assert_eq!(cache.ephemeral_5m_input_tokens, Some(1281));
207 assert_eq!(cache.ephemeral_1h_input_tokens, Some(0));
208
209 let content = api.content.unwrap();
210 assert_eq!(content.len(), 1);
211 match &content[0] {
212 ContentBlock::Text { text } => {
213 assert_eq!(text.as_deref(), Some("Hello"));
214 }
215 _ => panic!("expected Text content block"),
216 }
217 }
218 _ => panic!("expected Assistant variant"),
219 }
220 }
221
222 #[test]
223 fn test_parse_user_message() {
224 let json = r#"{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1","version":"2.1.80","cwd":"/tmp","gitBranch":"main","userType":"external"}"#;
225
226 let entry: JournalEntry = serde_json::from_str(json).unwrap();
227
228 match entry {
229 JournalEntry::User(msg) => {
230 assert_eq!(msg.uuid.as_deref(), Some("u1"));
231 assert_eq!(msg.session_id.as_deref(), Some("s1"));
232 assert_eq!(msg.version.as_deref(), Some("2.1.80"));
233 assert_eq!(msg.cwd.as_deref(), Some("/tmp"));
234 assert_eq!(msg.git_branch.as_deref(), Some("main"));
235 assert!(msg.parent_uuid.is_none());
236 }
237 _ => panic!("expected User variant"),
238 }
239 }
240
241 #[test]
242 fn test_parse_queue_operation() {
243 let json = r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-03-16T13:51:19.041Z","sessionId":"abc"}"#;
244
245 let entry: JournalEntry = serde_json::from_str(json).unwrap();
246
247 match entry {
248 JournalEntry::QueueOperation(val) => {
249 assert_eq!(val.get("operation").and_then(|v| v.as_str()), Some("dequeue"));
250 assert_eq!(val.get("sessionId").and_then(|v| v.as_str()), Some("abc"));
251 }
252 _ => panic!("expected QueueOperation variant"),
253 }
254 }
255
256 #[test]
257 fn test_parse_synthetic_message() {
258 let json = r#"{"type":"assistant","uuid":"x","timestamp":"2026-03-16T00:00:00Z","message":{"model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"content":[{"type":"text","text":"error"}]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null}"#;
259
260 let entry: JournalEntry = serde_json::from_str(json).unwrap();
261
262 match entry {
263 JournalEntry::Assistant(msg) => {
264 let api = msg.message.unwrap();
265 assert_eq!(api.model.as_deref(), Some("<synthetic>"));
266 assert_eq!(api.stop_reason.as_deref(), Some("stop_sequence"));
267
268 let usage = api.usage.unwrap();
269 assert_eq!(usage.input_tokens, Some(0));
270 assert_eq!(usage.output_tokens, Some(0));
271
272 assert!(usage.cache_creation.is_none());
274 }
275 _ => panic!("expected Assistant variant"),
276 }
277 }
278}