1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize)]
6#[serde(tag = "type", rename_all = "kebab-case")]
7pub enum Entry {
8 User(UserEntry),
9 Assistant(AssistantEntry),
10 #[serde(other)]
11 Other,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15pub struct UserEntry {
16 #[allow(dead_code)]
20 pub uuid: String,
21 #[serde(rename = "parentUuid")]
22 #[allow(dead_code)]
23 pub parent_uuid: Option<String>,
24 pub timestamp: Option<String>,
25 pub message: UserMessage,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct UserMessage {
30 #[allow(dead_code)]
33 pub role: String,
34 pub content: UserContent,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38#[serde(untagged)]
39pub enum UserContent {
40 Text(String),
41 Items(Vec<UserContentItem>),
42}
43
44#[derive(Debug, Clone, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum UserContentItem {
47 Text {
48 text: String,
49 },
50 ToolResult {
51 tool_use_id: String,
52 content: ToolResultContent,
53 },
54 #[serde(other)]
55 Other,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59#[serde(untagged)]
60pub enum ToolResultContent {
61 Text(String),
62 Items(Vec<serde_json::Value>),
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct AssistantEntry {
67 #[allow(dead_code)]
68 pub uuid: String,
69 #[serde(rename = "parentUuid")]
70 #[allow(dead_code)]
71 pub parent_uuid: Option<String>,
72 pub timestamp: Option<String>,
73 pub message: AssistantMessage,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77pub struct AssistantMessage {
78 #[allow(dead_code)]
79 pub role: String,
80 pub content: Vec<AssistantContentItem>,
81 #[serde(default)]
84 pub model: Option<String>,
85 #[serde(default)]
88 pub usage: Option<ClaudeUsage>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
93pub struct ClaudeUsage {
94 #[serde(default)]
95 pub input_tokens: Option<u64>,
96 #[serde(default)]
97 pub output_tokens: Option<u64>,
98 #[serde(default)]
99 pub cache_creation_input_tokens: Option<u64>,
100 #[serde(default)]
101 pub cache_read_input_tokens: Option<u64>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum AssistantContentItem {
107 Text {
108 text: String,
109 },
110 ToolUse {
111 id: String,
112 name: String,
113 input: serde_json::Value,
114 },
115 #[serde(other)]
116 Other,
117}
118
119pub fn load(path: &Path) -> Result<Vec<Entry>> {
120 use std::fs::File;
128 use std::io::{BufRead, BufReader};
129 let file =
130 File::open(path).with_context(|| format!("opening session file: {}", path.display()))?;
131 let reader = BufReader::new(file);
132 let mut entries = Vec::with_capacity(1024);
133 for (i, line) in reader.lines().enumerate() {
134 let line = line.with_context(|| format!("reading line {} of session file", i + 1))?;
135 if line.trim().is_empty() {
136 continue;
137 }
138 let entry: Entry = serde_json::from_str(&line)
139 .with_context(|| format!("parsing line {} of session file", i + 1))?;
140 entries.push(entry);
141 }
142 Ok(entries)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn parses_text_user_message() {
151 let line = r#"{"type":"user","uuid":"u1","parentUuid":null,"timestamp":"2026-04-11T00:00:00Z","message":{"role":"user","content":"hello"}}"#;
152 let entry: Entry = serde_json::from_str(line).unwrap();
153 let Entry::User(u) = entry else {
154 panic!("expected user");
155 };
156 assert!(matches!(u.message.content, UserContent::Text(ref s) if s == "hello"));
157 }
158
159 #[test]
160 fn parses_tool_result_user_message() {
161 let line = r#"{"type":"user","uuid":"u2","parentUuid":"u1","timestamp":"2026-04-11T00:00:01Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"output"}]}}"#;
162 let entry: Entry = serde_json::from_str(line).unwrap();
163 let Entry::User(u) = entry else {
164 panic!("expected user");
165 };
166 let UserContent::Items(items) = u.message.content else {
167 panic!("expected items");
168 };
169 assert_eq!(items.len(), 1);
170 assert!(
171 matches!(&items[0], UserContentItem::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
172 );
173 }
174
175 #[test]
176 fn parses_assistant_with_tool_use() {
177 let line = r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-04-11T00:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"thinking..."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/x"}}]}}"#;
178 let entry: Entry = serde_json::from_str(line).unwrap();
179 let Entry::Assistant(a) = entry else {
180 panic!("expected assistant");
181 };
182 assert_eq!(a.message.content.len(), 2);
183 assert!(
184 matches!(&a.message.content[1], AssistantContentItem::ToolUse { name, .. } if name == "Read")
185 );
186 }
187
188 #[test]
189 fn unknown_top_level_type_becomes_other() {
190 let line = r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s1"}"#;
191 let entry: Entry = serde_json::from_str(line).unwrap();
192 assert!(matches!(entry, Entry::Other));
193 }
194
195 #[test]
196 fn parses_usage_and_model_on_assistant_message() {
197 let line = r#"{"type":"assistant","uuid":"a1","parentUuid":null,"timestamp":null,"message":{"role":"assistant","model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":200},"content":[{"type":"text","text":"hi"}]}}"#;
198 let entry: Entry = serde_json::from_str(line).unwrap();
199 let Entry::Assistant(a) = entry else {
200 panic!("expected assistant");
201 };
202 assert_eq!(a.message.model.as_deref(), Some("claude-opus-4-6"));
203 let u = a.message.usage.as_ref().unwrap();
204 assert_eq!(u.input_tokens, Some(100));
205 assert_eq!(u.output_tokens, Some(50));
206 assert_eq!(u.cache_creation_input_tokens, Some(10));
207 assert_eq!(u.cache_read_input_tokens, Some(200));
208 }
209
210 #[test]
211 fn assistant_message_without_usage_parses_cleanly() {
212 let line = r#"{"type":"assistant","uuid":"a1","parentUuid":null,"timestamp":null,"message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#;
213 let entry: Entry = serde_json::from_str(line).unwrap();
214 let Entry::Assistant(a) = entry else {
215 panic!("expected assistant");
216 };
217 assert!(a.message.usage.is_none());
218 assert!(a.message.model.is_none());
219 }
220
221 #[test]
222 fn unknown_assistant_content_item_becomes_other() {
223 let line = r#"{"type":"assistant","uuid":"a2","parentUuid":null,"timestamp":null,"message":{"role":"assistant","content":[{"type":"thinking","content":"hmm"}]}}"#;
224 let entry: Entry = serde_json::from_str(line).unwrap();
225 let Entry::Assistant(a) = entry else {
226 panic!("expected assistant");
227 };
228 assert_eq!(a.message.content.len(), 1);
229 assert!(matches!(&a.message.content[0], AssistantContentItem::Other));
230 }
231}