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