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