1use crate::error::{ConvoError, Result};
2use crate::types::{Conversation, ConversationEntry, HistoryEntry};
3use std::fs::File;
4use std::io::{BufRead, BufReader, Seek, SeekFrom};
5use std::path::Path;
6
7pub struct ConversationReader;
8
9impl ConversationReader {
10 pub fn read_conversation<P: AsRef<Path>>(path: P) -> Result<Conversation> {
11 let path = path.as_ref();
12 if !path.exists() {
13 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
14 }
15
16 let file = File::open(path)?;
17 let reader = BufReader::new(file);
18
19 let session_id = path
20 .file_stem()
21 .and_then(|s| s.to_str())
22 .ok_or_else(|| ConvoError::InvalidFormat(path.to_path_buf()))?
23 .to_string();
24
25 let mut conversation = Conversation::new(session_id);
26
27 for (line_num, line) in reader.lines().enumerate() {
28 let line = line?;
29 if line.trim().is_empty() {
30 continue;
31 }
32
33 match serde_json::from_str::<ConversationEntry>(&line) {
35 Ok(entry) if !entry.uuid.is_empty() => {
36 conversation.add_entry(entry);
37 }
38 Ok(_) | Err(_) => {
39 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
44 conversation.preamble.push(value);
45 } else if line_num < 5 || std::env::var("CLAUDE_CLI_DEBUG").is_ok() {
46 eprintln!(
47 "Warning: Failed to parse line {} in {:?}: not valid JSON",
48 line_num + 1,
49 path.file_name().unwrap_or_default()
50 );
51 }
52 }
53 }
54 }
55
56 Ok(conversation)
57 }
58
59 pub fn read_conversation_metadata<P: AsRef<Path>>(
60 path: P,
61 ) -> Result<crate::types::ConversationMetadata> {
62 let path = path.as_ref();
63 if !path.exists() {
64 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
65 }
66
67 let session_id = path
68 .file_stem()
69 .and_then(|s| s.to_str())
70 .ok_or_else(|| ConvoError::InvalidFormat(path.to_path_buf()))?
71 .to_string();
72
73 let project_path = path
78 .parent()
79 .and_then(|p| p.file_name())
80 .and_then(|n| n.to_str())
81 .map(crate::paths::unsanitize_project_path)
82 .unwrap_or_default();
83
84 let file = File::open(path)?;
85 let reader = BufReader::new(file);
86
87 let mut message_count = 0;
88 let mut started_at = None;
89 let mut last_activity = None;
90 let mut first_user_message: Option<String> = None;
91
92 for line in reader.lines() {
93 let line = line?;
94 if line.trim().is_empty() {
95 continue;
96 }
97
98 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line)
99 && !entry.uuid.is_empty()
100 {
101 if entry.message.is_some() {
102 message_count += 1;
103 }
104
105 if first_user_message.is_none()
108 && entry.entry_type == "user"
109 && let Some(msg) = &entry.message
110 {
111 let text = msg.text();
112 let trimmed = text.trim();
113 if !trimmed.is_empty() {
114 first_user_message = Some(trimmed.to_string());
115 }
116 }
117
118 if !entry.timestamp.is_empty()
119 && let Ok(timestamp) =
120 entry.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
121 {
122 if started_at.is_none() || Some(timestamp) < started_at {
123 started_at = Some(timestamp);
124 }
125 if last_activity.is_none() || Some(timestamp) > last_activity {
126 last_activity = Some(timestamp);
127 }
128 }
129 }
130 }
131
132 Ok(crate::types::ConversationMetadata {
133 session_id,
134 project_path,
135 file_path: path.to_path_buf(),
136 message_count,
137 started_at,
138 last_activity,
139 first_user_message,
140 })
141 }
142
143 pub fn read_history<P: AsRef<Path>>(path: P) -> Result<Vec<HistoryEntry>> {
144 let path = path.as_ref();
145 if !path.exists() {
146 return Ok(Vec::new());
147 }
148
149 let file = File::open(path)?;
150 let reader = BufReader::new(file);
151 let mut history = Vec::new();
152
153 for line in reader.lines() {
154 let line = line?;
155 if line.trim().is_empty() {
156 continue;
157 }
158
159 match serde_json::from_str::<HistoryEntry>(&line) {
160 Ok(entry) => history.push(entry),
161 Err(e) => {
162 eprintln!("Warning: Failed to parse history line: {}", e);
163 }
164 }
165 }
166
167 Ok(history)
168 }
169
170 pub fn read_from_offset<P: AsRef<Path>>(
176 path: P,
177 byte_offset: u64,
178 ) -> Result<(Vec<ConversationEntry>, u64)> {
179 let path = path.as_ref();
180 if !path.exists() {
181 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
182 }
183
184 let mut file = File::open(path)?;
185 let file_len = file.metadata()?.len();
186
187 if byte_offset > file_len {
190 return Ok((Vec::new(), file_len));
191 }
192
193 file.seek(SeekFrom::Start(byte_offset))?;
195
196 let reader = BufReader::new(file);
197 let mut entries = Vec::new();
198 let mut current_offset = byte_offset;
199
200 for line in reader.lines() {
201 let line = line?;
202 current_offset += line.len() as u64 + 1;
204
205 if line.trim().is_empty() {
206 continue;
207 }
208
209 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
211 if !entry.uuid.is_empty() {
213 entries.push(entry);
214 }
215 }
216 }
218
219 Ok((entries, current_offset))
220 }
221
222 pub fn read_first_session_id<P: AsRef<Path>>(path: P) -> Option<String> {
229 let file = File::open(path.as_ref()).ok()?;
230 let reader = BufReader::new(file);
231
232 for line in reader.lines().take(10) {
233 let line = line.ok()?;
234 if line.trim().is_empty() {
235 continue;
236 }
237 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line)
238 && let Some(sid) = &entry.session_id
239 && !sid.is_empty()
240 {
241 return Some(sid.clone());
242 }
243 }
244 None
245 }
246
247 pub fn file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
250 let path = path.as_ref();
251 if !path.exists() {
252 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
253 }
254 Ok(std::fs::metadata(path)?.len())
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::fs;
262 use std::io::Write;
263 use tempfile::{NamedTempFile, TempDir};
264
265 #[test]
266 fn test_read_conversation() {
267 let mut temp = NamedTempFile::new().unwrap();
268 writeln!(
269 temp,
270 r#"{{"type":"user","uuid":"123","timestamp":"2024-01-01T00:00:00Z","sessionId":"test","message":{{"role":"user","content":"Hello"}}}}"#
271 )
272 .unwrap();
273 writeln!(
274 temp,
275 r#"{{"type":"assistant","uuid":"456","timestamp":"2024-01-01T00:00:01Z","sessionId":"test","message":{{"role":"assistant","content":"Hi there"}}}}"#
276 )
277 .unwrap();
278 temp.flush().unwrap();
279
280 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
281 assert_eq!(convo.entries.len(), 2);
282 assert_eq!(convo.message_count(), 2);
283 assert_eq!(convo.user_messages().len(), 1);
284 assert_eq!(convo.assistant_messages().len(), 1);
285 }
286
287 #[test]
288 fn test_read_history() {
289 let mut temp = NamedTempFile::new().unwrap();
290 writeln!(
291 temp,
292 r#"{{"display":"Test query","pastedContents":{{}},"timestamp":1234567890,"project":"/test/project","sessionId":"session-123"}}"#
293 )
294 .unwrap();
295 temp.flush().unwrap();
296
297 let history = ConversationReader::read_history(temp.path()).unwrap();
298 assert_eq!(history.len(), 1);
299 assert_eq!(history[0].display, "Test query");
300 assert_eq!(history[0].project, Some("/test/project".to_string()));
301 }
302
303 #[test]
304 fn test_read_history_nonexistent() {
305 let history = ConversationReader::read_history("/nonexistent/file.jsonl").unwrap();
306 assert!(history.is_empty());
307 }
308
309 #[test]
310 fn test_read_conversation_metadata() {
311 let temp = TempDir::new().unwrap();
316 let project_dir = temp.path().join("-Users-alex-Devel-myproject");
317 fs::create_dir_all(&project_dir).unwrap();
318 let file_path = project_dir.join("session-1.jsonl");
319 fs::write(
320 &file_path,
321 "\
322{\"type\":\"user\",\"uuid\":\"u1\",\"timestamp\":\"2024-01-01T00:00:00Z\",\"cwd\":\"/Users/ben/elsewhere\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}
323{\"type\":\"assistant\",\"uuid\":\"u2\",\"timestamp\":\"2024-01-01T00:01:00Z\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}
324",
325 )
326 .unwrap();
327
328 let meta = ConversationReader::read_conversation_metadata(&file_path).unwrap();
329 assert_eq!(meta.message_count, 2);
330 assert_eq!(meta.session_id, "session-1");
331 assert_eq!(meta.project_path, "/Users/alex/Devel/myproject");
332 assert!(meta.started_at.is_some());
333 assert!(meta.last_activity.is_some());
334 }
335
336 #[test]
337 fn test_read_conversation_metadata_nonexistent() {
338 let result = ConversationReader::read_conversation_metadata("/nonexistent/file.jsonl");
339 assert!(result.is_err());
340 }
341
342 #[test]
343 fn test_read_from_offset_initial() {
344 let mut temp = NamedTempFile::new().unwrap();
345 writeln!(
346 temp,
347 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
348 ).unwrap();
349 writeln!(
350 temp,
351 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
352 ).unwrap();
353 temp.flush().unwrap();
354
355 let (entries, new_offset) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
356 assert_eq!(entries.len(), 2);
357 assert!(new_offset > 0);
358 }
359
360 #[test]
361 fn test_read_from_offset_incremental() {
362 let mut temp = NamedTempFile::new().unwrap();
363 writeln!(
364 temp,
365 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
366 ).unwrap();
367 temp.flush().unwrap();
368
369 let (entries1, offset1) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
370 assert_eq!(entries1.len(), 1);
371
372 writeln!(
374 temp,
375 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
376 ).unwrap();
377 temp.flush().unwrap();
378
379 let (entries2, _) = ConversationReader::read_from_offset(temp.path(), offset1).unwrap();
380 assert_eq!(entries2.len(), 1);
381 assert_eq!(entries2[0].uuid, "u2");
382 }
383
384 #[test]
385 fn test_read_from_offset_past_eof() {
386 let mut temp = NamedTempFile::new().unwrap();
387 writeln!(temp, r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#).unwrap();
388 temp.flush().unwrap();
389
390 let (entries, _) = ConversationReader::read_from_offset(temp.path(), 99999).unwrap();
391 assert!(entries.is_empty());
392 }
393
394 #[test]
395 fn test_read_from_offset_nonexistent() {
396 let result = ConversationReader::read_from_offset("/nonexistent/file.jsonl", 0);
397 assert!(result.is_err());
398 }
399
400 #[test]
401 fn test_file_size() {
402 let mut temp = NamedTempFile::new().unwrap();
403 writeln!(temp, "some content").unwrap();
404 temp.flush().unwrap();
405
406 let size = ConversationReader::file_size(temp.path()).unwrap();
407 assert!(size > 0);
408 }
409
410 #[test]
411 fn test_file_size_nonexistent() {
412 let result = ConversationReader::file_size("/nonexistent/file.jsonl");
413 assert!(result.is_err());
414 }
415
416 #[test]
417 fn test_read_conversation_nonexistent() {
418 let result = ConversationReader::read_conversation("/nonexistent/file.jsonl");
419 assert!(result.is_err());
420 }
421
422 #[test]
423 fn test_read_conversation_skips_empty_uuid() {
424 let mut temp = NamedTempFile::new().unwrap();
425 writeln!(
427 temp,
428 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
429 )
430 .unwrap();
431 writeln!(
432 temp,
433 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
434 ).unwrap();
435 temp.flush().unwrap();
436
437 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
438 assert_eq!(convo.entries.len(), 1);
439 }
440
441 #[test]
442 fn test_read_conversation_skips_file_history_snapshot() {
443 let mut temp = NamedTempFile::new().unwrap();
444 writeln!(temp, r#"{{"type":"file-history-snapshot","data":{{}}}}"#).unwrap();
445 writeln!(
446 temp,
447 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
448 ).unwrap();
449 temp.flush().unwrap();
450
451 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
452 assert_eq!(convo.entries.len(), 1);
453 }
454
455 #[test]
456 fn test_read_conversation_handles_unknown_type() {
457 let mut temp = NamedTempFile::new().unwrap();
458 writeln!(temp, r#"{{"type":"some-unknown-type","data":"whatever"}}"#).unwrap();
460 writeln!(
461 temp,
462 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
463 ).unwrap();
464 temp.flush().unwrap();
465
466 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
467 assert_eq!(convo.entries.len(), 1);
468 }
469
470 #[test]
471 fn test_read_conversation_metadata_empty_file() {
472 let mut temp = NamedTempFile::new().unwrap();
473 writeln!(temp).unwrap(); temp.flush().unwrap();
475
476 let meta = ConversationReader::read_conversation_metadata(temp.path()).unwrap();
477 assert_eq!(meta.message_count, 0);
478 assert!(meta.started_at.is_none());
479 assert!(meta.last_activity.is_none());
480 }
481
482 #[test]
483 fn test_read_from_offset_skips_metadata() {
484 let mut temp = NamedTempFile::new().unwrap();
485 writeln!(
487 temp,
488 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
489 )
490 .unwrap();
491 writeln!(
492 temp,
493 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
494 ).unwrap();
495 temp.flush().unwrap();
496
497 let (entries, _) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
498 assert_eq!(entries.len(), 1);
499 assert_eq!(entries[0].uuid, "u1");
500 }
501
502 #[test]
503 fn test_read_first_session_id() {
504 let mut temp = NamedTempFile::new().unwrap();
505 writeln!(
506 temp,
507 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","sessionId":"sess-abc","message":{{"role":"user","content":"Hi"}}}}"#
508 )
509 .unwrap();
510 temp.flush().unwrap();
511
512 let sid = ConversationReader::read_first_session_id(temp.path());
513 assert_eq!(sid, Some("sess-abc".to_string()));
514 }
515
516 #[test]
517 fn test_read_first_session_id_no_session_id() {
518 let mut temp = NamedTempFile::new().unwrap();
519 writeln!(
520 temp,
521 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
522 )
523 .unwrap();
524 temp.flush().unwrap();
525
526 let sid = ConversationReader::read_first_session_id(temp.path());
527 assert!(sid.is_none());
528 }
529
530 #[test]
531 fn test_read_first_session_id_nonexistent() {
532 let sid = ConversationReader::read_first_session_id("/nonexistent/file.jsonl");
533 assert!(sid.is_none());
534 }
535
536 #[test]
537 fn test_read_conversation_handles_blank_lines() {
538 let mut temp = NamedTempFile::new().unwrap();
539 writeln!(temp).unwrap(); writeln!(
541 temp,
542 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
543 ).unwrap();
544 writeln!(temp).unwrap(); temp.flush().unwrap();
546
547 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
548 assert_eq!(convo.entries.len(), 1);
549 }
550}