1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4pub use crate::session::SessionSource;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Message {
10 pub session_id: String,
11 pub role: String,
12 pub content: String,
13 pub timestamp: DateTime<Utc>,
14 pub branch: Option<String>,
15 pub line_number: usize,
16 pub uuid: Option<String>,
17 pub parent_uuid: Option<String>,
18}
19
20impl Message {
21 pub fn from_jsonl(line: &str, line_number: usize) -> Option<Self> {
25 use crate::session;
26
27 let json: serde_json::Value = serde_json::from_str(line).ok()?;
28 if session::is_synthetic_linear_record(&json) {
29 return None;
30 }
31
32 let msg_type = session::extract_record_type(&json)?;
34 if msg_type != "user" && msg_type != "assistant" {
35 return None;
36 }
37
38 let message = json.get("message")?;
39 let role = message.get("role")?.as_str()?.to_string();
40 let content_raw = message.get("content")?;
41 let content = Self::extract_content(content_raw);
42
43 if content.trim().is_empty() {
45 return None;
46 }
47
48 let session_id = session::extract_session_id(&json)?;
49 let timestamp = session::extract_timestamp(&json)?;
50
51 let branch = json
53 .get("branch")
54 .or_else(|| json.get("gitBranch"))
55 .and_then(|b| b.as_str())
56 .filter(|s| !s.is_empty())
57 .map(|s| s.to_string());
58
59 let uuid = session::extract_uuid(&json);
60 let parent_uuid = session::extract_parent_uuid(&json);
61
62 Some(Message {
63 session_id,
64 role,
65 content,
66 timestamp,
67 branch,
68 line_number,
69 uuid,
70 parent_uuid,
71 })
72 }
73
74 pub fn extract_content(raw: &serde_json::Value) -> String {
77 if let Some(s) = raw.as_str() {
79 return s.to_string();
80 }
81
82 let mut parts: Vec<String> = Vec::new();
83
84 if let Some(arr) = raw.as_array() {
85 for item in arr {
86 let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
87
88 match item_type {
89 "text" => {
90 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
91 parts.push(text.to_string());
92 }
93 }
94 "tool_use" => {
95 if let Some(input) = item.get("input") {
97 if let Ok(json_str) = serde_json::to_string(input) {
98 parts.push(json_str);
99 }
100 }
101 }
102 "tool_result" => {
103 if let Some(content) = item.get("content") {
104 if let Some(s) = content.as_str() {
105 parts.push(s.to_string());
106 } else if let Ok(json_str) = serde_json::to_string(content) {
107 parts.push(json_str);
108 }
109 }
110 }
111 _ => {}
112 }
113 }
114 }
115
116 parts.join("\n")
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn test_parse_user_message() {
126 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
127
128 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse user message");
129
130 assert_eq!(msg.session_id, "abc123");
131 assert_eq!(msg.role, "user");
132 assert_eq!(msg.content, "Hello Claude");
133 assert_eq!(msg.line_number, 1);
134 }
135
136 #[test]
137 fn test_parse_assistant_message() {
138 let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help?"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:01:00Z"}"#;
139
140 let msg = Message::from_jsonl(jsonl, 2).expect("Should parse assistant message");
141
142 assert_eq!(msg.role, "assistant");
143 assert_eq!(msg.content, "Hello! How can I help?");
144 }
145
146 #[test]
147 fn test_parse_message_with_branch() {
148 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Fix bug"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","cwd":"/projects/myapp","branch":"feature/fix-bug"}"#;
149
150 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message with branch");
151
152 assert_eq!(msg.branch, Some("feature/fix-bug".to_string()));
153 }
154
155 #[test]
156 fn test_parse_message_multiple_text_blocks() {
157 let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
158
159 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse multiple text blocks");
160
161 assert_eq!(msg.content, "Part 1\nPart 2");
162 }
163
164 #[test]
165 fn test_parse_tool_use_message() {
166 let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/tmp/test.txt"}}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
167
168 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse tool_use message");
169
170 assert!(msg.content.contains("file_path"));
171 assert!(msg.content.contains("/tmp/test.txt"));
172 }
173
174 #[test]
175 fn test_skip_summary_type() {
176 let jsonl = r#"{"type":"summary","summary":"Session summary text","sessionId":"abc123"}"#;
177
178 let msg = Message::from_jsonl(jsonl, 1);
179
180 assert!(msg.is_none(), "Should skip summary type messages");
181 }
182
183 #[test]
184 fn test_skip_invalid_json() {
185 let jsonl = "not valid json {{{";
186
187 let msg = Message::from_jsonl(jsonl, 1);
188
189 assert!(msg.is_none(), "Should skip invalid JSON");
190 }
191
192 #[test]
193 fn test_extract_content_from_text_block() {
194 let raw: serde_json::Value = serde_json::json!([
195 {"type": "text", "text": "Hello world"}
196 ]);
197
198 let content = Message::extract_content(&raw);
199
200 assert_eq!(content, "Hello world");
201 }
202
203 #[test]
204 fn test_extract_content_from_tool_result() {
205 let raw: serde_json::Value = serde_json::json!([
206 {"type": "tool_result", "content": "File contents here"}
207 ]);
208
209 let content = Message::extract_content(&raw);
210
211 assert!(content.contains("File contents here"));
212 }
213
214 #[test]
215 fn test_parse_desktop_format_message() {
216 let jsonl = r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","id":"msg_01Q4WpB2jNfsHuijFwLGLFwN","type":"message","role":"assistant","content":[{"type":"text","text":"I'd love to help you analyze how you're spending your time!"}],"stop_reason":null,"stop_sequence":null},"parent_tool_use_id":null,"session_id":"0c2f5015-9457-491f-8f35-218d6c34ff68","uuid":"aa419e76-930a-4970-85c3-b5f03e85f6e0","_audit_timestamp":"2026-01-13T13:31:31.268Z"}"#;
218
219 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse Desktop format message");
220
221 assert_eq!(msg.session_id, "0c2f5015-9457-491f-8f35-218d6c34ff68");
222 assert_eq!(msg.role, "assistant");
223 assert!(msg.content.contains("I'd love to help you analyze"));
224 assert!(msg.branch.is_none()); assert_eq!(
226 msg.uuid,
227 Some("aa419e76-930a-4970-85c3-b5f03e85f6e0".to_string())
228 );
229 }
230
231 #[test]
232 fn test_parse_desktop_user_message() {
233 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello from Desktop"}]},"session_id":"desktop-session-123","_audit_timestamp":"2026-01-13T10:00:00.000Z"}"#;
234
235 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse Desktop user message");
236
237 assert_eq!(msg.session_id, "desktop-session-123");
238 assert_eq!(msg.role, "user");
239 assert_eq!(msg.content, "Hello from Desktop");
240 }
241
242 #[test]
243 fn test_parse_string_content() {
244 let jsonl = r#"{"type":"user","message":{"role":"user","content":"Hello plain string"},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
246
247 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse plain string content");
248
249 assert_eq!(msg.content, "Hello plain string");
250 assert_eq!(msg.role, "user");
251 }
252
253 #[test]
254 fn test_parse_uuid_and_parent_uuid() {
255 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","uuid":"uuid-111","parentUuid":"uuid-000"}"#;
256
257 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message with uuid");
258
259 assert_eq!(msg.uuid, Some("uuid-111".to_string()));
260 assert_eq!(msg.parent_uuid, Some("uuid-000".to_string()));
261 }
262
263 #[test]
264 fn test_parse_message_without_uuid() {
265 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
266
267 let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message without uuid");
268
269 assert_eq!(msg.uuid, None);
270 assert_eq!(msg.parent_uuid, None);
271 }
272
273 #[test]
274 fn test_skip_synthetic_linear_message() {
275 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hidden synthetic session"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","ccsSyntheticLinear":true}"#;
276
277 let msg = Message::from_jsonl(jsonl, 1);
278
279 assert!(
280 msg.is_none(),
281 "Synthetic linear sessions should not be indexed"
282 );
283 }
284
285 #[test]
286 fn test_extract_content_string() {
287 let raw: serde_json::Value = serde_json::json!("plain text content");
288
289 let content = Message::extract_content(&raw);
290
291 assert_eq!(content, "plain text content");
292 }
293}