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) => {
36 if !entry.uuid.is_empty() {
38 conversation.add_entry(entry);
39 }
40 }
41 Err(_) => {
42 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
44 && let Some(entry_type) = value.get("type").and_then(|t| t.as_str())
45 {
46 if entry_type == "file-history-snapshot" {
48 continue;
50 }
51 }
52
53 if line_num < 5 || std::env::var("CLAUDE_CLI_DEBUG").is_ok() {
55 eprintln!(
56 "Warning: Failed to parse line {} in {:?}: entry type not recognized",
57 line_num + 1,
58 path.file_name().unwrap_or_default()
59 );
60 }
61 }
62 }
63 }
64
65 Ok(conversation)
66 }
67
68 pub fn read_conversation_metadata<P: AsRef<Path>>(
69 path: P,
70 ) -> Result<crate::types::ConversationMetadata> {
71 let path = path.as_ref();
72 if !path.exists() {
73 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
74 }
75
76 let session_id = path
77 .file_stem()
78 .and_then(|s| s.to_str())
79 .ok_or_else(|| ConvoError::InvalidFormat(path.to_path_buf()))?
80 .to_string();
81
82 let file = File::open(path)?;
83 let reader = BufReader::new(file);
84
85 let mut message_count = 0;
86 let mut started_at = None;
87 let mut last_activity = None;
88 let mut project_path = String::new();
89 let mut first_user_message: Option<String> = None;
90
91 for line in reader.lines() {
92 let line = line?;
93 if line.trim().is_empty() {
94 continue;
95 }
96
97 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
99 if !entry.uuid.is_empty() {
101 if entry.message.is_some() {
102 message_count += 1;
103 }
104
105 if project_path.is_empty()
106 && let Some(cwd) = entry.cwd
107 {
108 project_path = cwd;
109 }
110
111 if first_user_message.is_none()
114 && entry.entry_type == "user"
115 && let Some(msg) = &entry.message
116 {
117 let text = msg.text();
118 let trimmed = text.trim();
119 if !trimmed.is_empty() {
120 first_user_message = Some(trimmed.to_string());
121 }
122 }
123
124 if !entry.timestamp.is_empty()
125 && let Ok(timestamp) =
126 entry.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
127 {
128 if started_at.is_none() || Some(timestamp) < started_at {
129 started_at = Some(timestamp);
130 }
131 if last_activity.is_none() || Some(timestamp) > last_activity {
132 last_activity = Some(timestamp);
133 }
134 }
135 }
136 }
137 }
138
139 Ok(crate::types::ConversationMetadata {
140 session_id,
141 project_path,
142 file_path: path.to_path_buf(),
143 message_count,
144 started_at,
145 last_activity,
146 first_user_message,
147 })
148 }
149
150 pub fn read_history<P: AsRef<Path>>(path: P) -> Result<Vec<HistoryEntry>> {
151 let path = path.as_ref();
152 if !path.exists() {
153 return Ok(Vec::new());
154 }
155
156 let file = File::open(path)?;
157 let reader = BufReader::new(file);
158 let mut history = Vec::new();
159
160 for line in reader.lines() {
161 let line = line?;
162 if line.trim().is_empty() {
163 continue;
164 }
165
166 match serde_json::from_str::<HistoryEntry>(&line) {
167 Ok(entry) => history.push(entry),
168 Err(e) => {
169 eprintln!("Warning: Failed to parse history line: {}", e);
170 }
171 }
172 }
173
174 Ok(history)
175 }
176
177 pub fn read_from_offset<P: AsRef<Path>>(
183 path: P,
184 byte_offset: u64,
185 ) -> Result<(Vec<ConversationEntry>, u64)> {
186 let path = path.as_ref();
187 if !path.exists() {
188 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
189 }
190
191 let mut file = File::open(path)?;
192 let file_len = file.metadata()?.len();
193
194 if byte_offset > file_len {
197 return Ok((Vec::new(), file_len));
198 }
199
200 file.seek(SeekFrom::Start(byte_offset))?;
202
203 let reader = BufReader::new(file);
204 let mut entries = Vec::new();
205 let mut current_offset = byte_offset;
206
207 for line in reader.lines() {
208 let line = line?;
209 current_offset += line.len() as u64 + 1;
211
212 if line.trim().is_empty() {
213 continue;
214 }
215
216 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
218 if !entry.uuid.is_empty() {
220 entries.push(entry);
221 }
222 }
223 }
225
226 Ok((entries, current_offset))
227 }
228
229 pub fn read_first_session_id<P: AsRef<Path>>(path: P) -> Option<String> {
236 let file = File::open(path.as_ref()).ok()?;
237 let reader = BufReader::new(file);
238
239 for line in reader.lines().take(10) {
240 let line = line.ok()?;
241 if line.trim().is_empty() {
242 continue;
243 }
244 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line)
245 && let Some(sid) = &entry.session_id
246 && !sid.is_empty()
247 {
248 return Some(sid.clone());
249 }
250 }
251 None
252 }
253
254 pub fn file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
257 let path = path.as_ref();
258 if !path.exists() {
259 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
260 }
261 Ok(std::fs::metadata(path)?.len())
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::io::Write;
269 use tempfile::NamedTempFile;
270
271 #[test]
272 fn test_read_conversation() {
273 let mut temp = NamedTempFile::new().unwrap();
274 writeln!(
275 temp,
276 r#"{{"type":"user","uuid":"123","timestamp":"2024-01-01T00:00:00Z","sessionId":"test","message":{{"role":"user","content":"Hello"}}}}"#
277 )
278 .unwrap();
279 writeln!(
280 temp,
281 r#"{{"type":"assistant","uuid":"456","timestamp":"2024-01-01T00:00:01Z","sessionId":"test","message":{{"role":"assistant","content":"Hi there"}}}}"#
282 )
283 .unwrap();
284 temp.flush().unwrap();
285
286 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
287 assert_eq!(convo.entries.len(), 2);
288 assert_eq!(convo.message_count(), 2);
289 assert_eq!(convo.user_messages().len(), 1);
290 assert_eq!(convo.assistant_messages().len(), 1);
291 }
292
293 #[test]
294 fn test_read_history() {
295 let mut temp = NamedTempFile::new().unwrap();
296 writeln!(
297 temp,
298 r#"{{"display":"Test query","pastedContents":{{}},"timestamp":1234567890,"project":"/test/project","sessionId":"session-123"}}"#
299 )
300 .unwrap();
301 temp.flush().unwrap();
302
303 let history = ConversationReader::read_history(temp.path()).unwrap();
304 assert_eq!(history.len(), 1);
305 assert_eq!(history[0].display, "Test query");
306 assert_eq!(history[0].project, Some("/test/project".to_string()));
307 }
308
309 #[test]
310 fn test_read_history_nonexistent() {
311 let history = ConversationReader::read_history("/nonexistent/file.jsonl").unwrap();
312 assert!(history.is_empty());
313 }
314
315 #[test]
316 fn test_read_conversation_metadata() {
317 let mut temp = NamedTempFile::new().unwrap();
318 writeln!(
319 temp,
320 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","cwd":"/my/project","message":{{"role":"user","content":"Hello"}}}}"#
321 ).unwrap();
322 writeln!(
323 temp,
324 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:01:00Z","message":{{"role":"assistant","content":"Hi"}}}}"#
325 ).unwrap();
326 temp.flush().unwrap();
327
328 let meta = ConversationReader::read_conversation_metadata(temp.path()).unwrap();
329 assert_eq!(meta.message_count, 2);
330 assert_eq!(meta.project_path, "/my/project");
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}