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) = entry.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
120 {
121 if started_at.is_none() || Some(timestamp) < started_at {
122 started_at = Some(timestamp);
123 }
124 if last_activity.is_none() || Some(timestamp) > last_activity {
125 last_activity = Some(timestamp);
126 }
127 }
128 }
129 }
130
131 Ok(crate::types::ConversationMetadata {
132 session_id,
133 project_path,
134 file_path: path.to_path_buf(),
135 message_count,
136 started_at,
137 last_activity,
138 first_user_message,
139 })
140 }
141
142 pub fn read_history<P: AsRef<Path>>(path: P) -> Result<Vec<HistoryEntry>> {
143 let path = path.as_ref();
144 if !path.exists() {
145 return Ok(Vec::new());
146 }
147
148 let file = File::open(path)?;
149 let reader = BufReader::new(file);
150 let mut history = Vec::new();
151
152 for line in reader.lines() {
153 let line = line?;
154 if line.trim().is_empty() {
155 continue;
156 }
157
158 match serde_json::from_str::<HistoryEntry>(&line) {
159 Ok(entry) => history.push(entry),
160 Err(e) => {
161 eprintln!("Warning: Failed to parse history line: {}", e);
162 }
163 }
164 }
165
166 Ok(history)
167 }
168
169 pub fn read_from_offset<P: AsRef<Path>>(
175 path: P,
176 byte_offset: u64,
177 ) -> Result<(Vec<ConversationEntry>, u64)> {
178 let path = path.as_ref();
179 if !path.exists() {
180 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
181 }
182
183 let mut file = File::open(path)?;
184 let file_len = file.metadata()?.len();
185
186 if byte_offset > file_len {
189 return Ok((Vec::new(), file_len));
190 }
191
192 file.seek(SeekFrom::Start(byte_offset))?;
194
195 let reader = BufReader::new(file);
196 let mut entries = Vec::new();
197 let mut current_offset = byte_offset;
198
199 for line in reader.lines() {
200 let line = line?;
201 current_offset += line.len() as u64 + 1;
203
204 if line.trim().is_empty() {
205 continue;
206 }
207
208 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
210 if !entry.uuid.is_empty() {
212 entries.push(entry);
213 }
214 }
215 }
217
218 Ok((entries, current_offset))
219 }
220
221 pub fn read_first_session_id<P: AsRef<Path>>(path: P) -> Option<String> {
228 let file = File::open(path.as_ref()).ok()?;
229 let reader = BufReader::new(file);
230
231 for line in reader.lines().take(10) {
232 let line = line.ok()?;
233 if line.trim().is_empty() {
234 continue;
235 }
236 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line)
237 && let Some(sid) = &entry.session_id
238 && !sid.is_empty()
239 {
240 return Some(sid.clone());
241 }
242 }
243 None
244 }
245
246 pub fn file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
249 let path = path.as_ref();
250 if !path.exists() {
251 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
252 }
253 Ok(std::fs::metadata(path)?.len())
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use std::fs;
261 use std::io::Write;
262 use tempfile::{NamedTempFile, TempDir};
263
264 #[test]
265 fn test_read_conversation() {
266 let mut temp = NamedTempFile::new().unwrap();
267 writeln!(
268 temp,
269 r#"{{"type":"user","uuid":"123","timestamp":"2024-01-01T00:00:00Z","sessionId":"test","message":{{"role":"user","content":"Hello"}}}}"#
270 )
271 .unwrap();
272 writeln!(
273 temp,
274 r#"{{"type":"assistant","uuid":"456","timestamp":"2024-01-01T00:00:01Z","sessionId":"test","message":{{"role":"assistant","content":"Hi there"}}}}"#
275 )
276 .unwrap();
277 temp.flush().unwrap();
278
279 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
280 assert_eq!(convo.entries.len(), 2);
281 assert_eq!(convo.message_count(), 2);
282 assert_eq!(convo.user_messages().len(), 1);
283 assert_eq!(convo.assistant_messages().len(), 1);
284 }
285
286 #[test]
287 fn test_read_history() {
288 let mut temp = NamedTempFile::new().unwrap();
289 writeln!(
290 temp,
291 r#"{{"display":"Test query","pastedContents":{{}},"timestamp":1234567890,"project":"/test/project","sessionId":"session-123"}}"#
292 )
293 .unwrap();
294 temp.flush().unwrap();
295
296 let history = ConversationReader::read_history(temp.path()).unwrap();
297 assert_eq!(history.len(), 1);
298 assert_eq!(history[0].display, "Test query");
299 assert_eq!(history[0].project, Some("/test/project".to_string()));
300 }
301
302 #[test]
303 fn test_read_history_nonexistent() {
304 let history = ConversationReader::read_history("/nonexistent/file.jsonl").unwrap();
305 assert!(history.is_empty());
306 }
307
308 #[test]
309 fn test_read_conversation_metadata() {
310 let temp = TempDir::new().unwrap();
315 let project_dir = temp.path().join("-Users-alex-Devel-myproject");
316 fs::create_dir_all(&project_dir).unwrap();
317 let file_path = project_dir.join("session-1.jsonl");
318 fs::write(
319 &file_path,
320 "\
321{\"type\":\"user\",\"uuid\":\"u1\",\"timestamp\":\"2024-01-01T00:00:00Z\",\"cwd\":\"/Users/ben/elsewhere\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}
322{\"type\":\"assistant\",\"uuid\":\"u2\",\"timestamp\":\"2024-01-01T00:01:00Z\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}
323",
324 )
325 .unwrap();
326
327 let meta = ConversationReader::read_conversation_metadata(&file_path).unwrap();
328 assert_eq!(meta.message_count, 2);
329 assert_eq!(meta.session_id, "session-1");
330 assert_eq!(meta.project_path, "/Users/alex/Devel/myproject");
331 assert!(meta.started_at.is_some());
332 assert!(meta.last_activity.is_some());
333 }
334
335 #[test]
336 fn test_read_conversation_metadata_nonexistent() {
337 let result = ConversationReader::read_conversation_metadata("/nonexistent/file.jsonl");
338 assert!(result.is_err());
339 }
340
341 #[test]
342 fn test_read_from_offset_initial() {
343 let mut temp = NamedTempFile::new().unwrap();
344 writeln!(
345 temp,
346 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
347 ).unwrap();
348 writeln!(
349 temp,
350 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
351 ).unwrap();
352 temp.flush().unwrap();
353
354 let (entries, new_offset) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
355 assert_eq!(entries.len(), 2);
356 assert!(new_offset > 0);
357 }
358
359 #[test]
360 fn test_read_from_offset_incremental() {
361 let mut temp = NamedTempFile::new().unwrap();
362 writeln!(
363 temp,
364 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
365 ).unwrap();
366 temp.flush().unwrap();
367
368 let (entries1, offset1) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
369 assert_eq!(entries1.len(), 1);
370
371 writeln!(
373 temp,
374 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
375 ).unwrap();
376 temp.flush().unwrap();
377
378 let (entries2, _) = ConversationReader::read_from_offset(temp.path(), offset1).unwrap();
379 assert_eq!(entries2.len(), 1);
380 assert_eq!(entries2[0].uuid, "u2");
381 }
382
383 #[test]
384 fn test_read_from_offset_past_eof() {
385 let mut temp = NamedTempFile::new().unwrap();
386 writeln!(temp, r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#).unwrap();
387 temp.flush().unwrap();
388
389 let (entries, _) = ConversationReader::read_from_offset(temp.path(), 99999).unwrap();
390 assert!(entries.is_empty());
391 }
392
393 #[test]
394 fn test_read_from_offset_nonexistent() {
395 let result = ConversationReader::read_from_offset("/nonexistent/file.jsonl", 0);
396 assert!(result.is_err());
397 }
398
399 #[test]
400 fn test_file_size() {
401 let mut temp = NamedTempFile::new().unwrap();
402 writeln!(temp, "some content").unwrap();
403 temp.flush().unwrap();
404
405 let size = ConversationReader::file_size(temp.path()).unwrap();
406 assert!(size > 0);
407 }
408
409 #[test]
410 fn test_file_size_nonexistent() {
411 let result = ConversationReader::file_size("/nonexistent/file.jsonl");
412 assert!(result.is_err());
413 }
414
415 #[test]
416 fn test_read_conversation_nonexistent() {
417 let result = ConversationReader::read_conversation("/nonexistent/file.jsonl");
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn test_read_conversation_skips_empty_uuid() {
423 let mut temp = NamedTempFile::new().unwrap();
424 writeln!(
426 temp,
427 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
428 )
429 .unwrap();
430 writeln!(
431 temp,
432 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
433 ).unwrap();
434 temp.flush().unwrap();
435
436 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
437 assert_eq!(convo.entries.len(), 1);
438 }
439
440 #[test]
441 fn test_read_conversation_skips_file_history_snapshot() {
442 let mut temp = NamedTempFile::new().unwrap();
443 writeln!(temp, r#"{{"type":"file-history-snapshot","data":{{}}}}"#).unwrap();
444 writeln!(
445 temp,
446 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
447 ).unwrap();
448 temp.flush().unwrap();
449
450 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
451 assert_eq!(convo.entries.len(), 1);
452 }
453
454 #[test]
455 fn test_read_conversation_handles_unknown_type() {
456 let mut temp = NamedTempFile::new().unwrap();
457 writeln!(temp, r#"{{"type":"some-unknown-type","data":"whatever"}}"#).unwrap();
459 writeln!(
460 temp,
461 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
462 ).unwrap();
463 temp.flush().unwrap();
464
465 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
466 assert_eq!(convo.entries.len(), 1);
467 }
468
469 #[test]
470 fn test_read_conversation_metadata_empty_file() {
471 let mut temp = NamedTempFile::new().unwrap();
472 writeln!(temp).unwrap(); temp.flush().unwrap();
474
475 let meta = ConversationReader::read_conversation_metadata(temp.path()).unwrap();
476 assert_eq!(meta.message_count, 0);
477 assert!(meta.started_at.is_none());
478 assert!(meta.last_activity.is_none());
479 }
480
481 #[test]
482 fn test_read_from_offset_skips_metadata() {
483 let mut temp = NamedTempFile::new().unwrap();
484 writeln!(
486 temp,
487 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
488 )
489 .unwrap();
490 writeln!(
491 temp,
492 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
493 ).unwrap();
494 temp.flush().unwrap();
495
496 let (entries, _) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
497 assert_eq!(entries.len(), 1);
498 assert_eq!(entries[0].uuid, "u1");
499 }
500
501 #[test]
502 fn test_read_first_session_id() {
503 let mut temp = NamedTempFile::new().unwrap();
504 writeln!(
505 temp,
506 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","sessionId":"sess-abc","message":{{"role":"user","content":"Hi"}}}}"#
507 )
508 .unwrap();
509 temp.flush().unwrap();
510
511 let sid = ConversationReader::read_first_session_id(temp.path());
512 assert_eq!(sid, Some("sess-abc".to_string()));
513 }
514
515 #[test]
516 fn test_read_first_session_id_no_session_id() {
517 let mut temp = NamedTempFile::new().unwrap();
518 writeln!(
519 temp,
520 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
521 )
522 .unwrap();
523 temp.flush().unwrap();
524
525 let sid = ConversationReader::read_first_session_id(temp.path());
526 assert!(sid.is_none());
527 }
528
529 #[test]
530 fn test_read_first_session_id_nonexistent() {
531 let sid = ConversationReader::read_first_session_id("/nonexistent/file.jsonl");
532 assert!(sid.is_none());
533 }
534
535 #[test]
536 fn test_read_conversation_handles_blank_lines() {
537 let mut temp = NamedTempFile::new().unwrap();
538 writeln!(temp).unwrap(); writeln!(
540 temp,
541 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
542 ).unwrap();
543 writeln!(temp).unwrap(); temp.flush().unwrap();
545
546 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
547 assert_eq!(convo.entries.len(), 1);
548 }
549}