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 file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
217 let path = path.as_ref();
218 if !path.exists() {
219 return Err(ConvoError::ConversationNotFound(path.display().to_string()));
220 }
221 Ok(std::fs::metadata(path)?.len())
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::io::Write;
229 use tempfile::NamedTempFile;
230
231 #[test]
232 fn test_read_conversation() {
233 let mut temp = NamedTempFile::new().unwrap();
234 writeln!(
235 temp,
236 r#"{{"type":"user","uuid":"123","timestamp":"2024-01-01T00:00:00Z","sessionId":"test","message":{{"role":"user","content":"Hello"}}}}"#
237 )
238 .unwrap();
239 writeln!(
240 temp,
241 r#"{{"type":"assistant","uuid":"456","timestamp":"2024-01-01T00:00:01Z","sessionId":"test","message":{{"role":"assistant","content":"Hi there"}}}}"#
242 )
243 .unwrap();
244 temp.flush().unwrap();
245
246 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
247 assert_eq!(convo.entries.len(), 2);
248 assert_eq!(convo.message_count(), 2);
249 assert_eq!(convo.user_messages().len(), 1);
250 assert_eq!(convo.assistant_messages().len(), 1);
251 }
252
253 #[test]
254 fn test_read_history() {
255 let mut temp = NamedTempFile::new().unwrap();
256 writeln!(
257 temp,
258 r#"{{"display":"Test query","pastedContents":{{}},"timestamp":1234567890,"project":"/test/project","sessionId":"session-123"}}"#
259 )
260 .unwrap();
261 temp.flush().unwrap();
262
263 let history = ConversationReader::read_history(temp.path()).unwrap();
264 assert_eq!(history.len(), 1);
265 assert_eq!(history[0].display, "Test query");
266 assert_eq!(history[0].project, Some("/test/project".to_string()));
267 }
268
269 #[test]
270 fn test_read_history_nonexistent() {
271 let history = ConversationReader::read_history("/nonexistent/file.jsonl").unwrap();
272 assert!(history.is_empty());
273 }
274
275 #[test]
276 fn test_read_conversation_metadata() {
277 let mut temp = NamedTempFile::new().unwrap();
278 writeln!(
279 temp,
280 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","cwd":"/my/project","message":{{"role":"user","content":"Hello"}}}}"#
281 ).unwrap();
282 writeln!(
283 temp,
284 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:01:00Z","message":{{"role":"assistant","content":"Hi"}}}}"#
285 ).unwrap();
286 temp.flush().unwrap();
287
288 let meta = ConversationReader::read_conversation_metadata(temp.path()).unwrap();
289 assert_eq!(meta.message_count, 2);
290 assert_eq!(meta.project_path, "/my/project");
291 assert!(meta.started_at.is_some());
292 assert!(meta.last_activity.is_some());
293 }
294
295 #[test]
296 fn test_read_conversation_metadata_nonexistent() {
297 let result = ConversationReader::read_conversation_metadata("/nonexistent/file.jsonl");
298 assert!(result.is_err());
299 }
300
301 #[test]
302 fn test_read_from_offset_initial() {
303 let mut temp = NamedTempFile::new().unwrap();
304 writeln!(
305 temp,
306 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
307 ).unwrap();
308 writeln!(
309 temp,
310 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
311 ).unwrap();
312 temp.flush().unwrap();
313
314 let (entries, new_offset) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
315 assert_eq!(entries.len(), 2);
316 assert!(new_offset > 0);
317 }
318
319 #[test]
320 fn test_read_from_offset_incremental() {
321 let mut temp = NamedTempFile::new().unwrap();
322 writeln!(
323 temp,
324 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hello"}}}}"#
325 ).unwrap();
326 temp.flush().unwrap();
327
328 let (entries1, offset1) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
329 assert_eq!(entries1.len(), 1);
330
331 writeln!(
333 temp,
334 r#"{{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"Hi"}}}}"#
335 ).unwrap();
336 temp.flush().unwrap();
337
338 let (entries2, _) = ConversationReader::read_from_offset(temp.path(), offset1).unwrap();
339 assert_eq!(entries2.len(), 1);
340 assert_eq!(entries2[0].uuid, "u2");
341 }
342
343 #[test]
344 fn test_read_from_offset_past_eof() {
345 let mut temp = NamedTempFile::new().unwrap();
346 writeln!(temp, r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#).unwrap();
347 temp.flush().unwrap();
348
349 let (entries, _) = ConversationReader::read_from_offset(temp.path(), 99999).unwrap();
350 assert!(entries.is_empty());
351 }
352
353 #[test]
354 fn test_read_from_offset_nonexistent() {
355 let result = ConversationReader::read_from_offset("/nonexistent/file.jsonl", 0);
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn test_file_size() {
361 let mut temp = NamedTempFile::new().unwrap();
362 writeln!(temp, "some content").unwrap();
363 temp.flush().unwrap();
364
365 let size = ConversationReader::file_size(temp.path()).unwrap();
366 assert!(size > 0);
367 }
368
369 #[test]
370 fn test_file_size_nonexistent() {
371 let result = ConversationReader::file_size("/nonexistent/file.jsonl");
372 assert!(result.is_err());
373 }
374
375 #[test]
376 fn test_read_conversation_nonexistent() {
377 let result = ConversationReader::read_conversation("/nonexistent/file.jsonl");
378 assert!(result.is_err());
379 }
380
381 #[test]
382 fn test_read_conversation_skips_empty_uuid() {
383 let mut temp = NamedTempFile::new().unwrap();
384 writeln!(
386 temp,
387 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
388 )
389 .unwrap();
390 writeln!(
391 temp,
392 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
393 ).unwrap();
394 temp.flush().unwrap();
395
396 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
397 assert_eq!(convo.entries.len(), 1);
398 }
399
400 #[test]
401 fn test_read_conversation_skips_file_history_snapshot() {
402 let mut temp = NamedTempFile::new().unwrap();
403 writeln!(temp, r#"{{"type":"file-history-snapshot","data":{{}}}}"#).unwrap();
404 writeln!(
405 temp,
406 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
407 ).unwrap();
408 temp.flush().unwrap();
409
410 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
411 assert_eq!(convo.entries.len(), 1);
412 }
413
414 #[test]
415 fn test_read_conversation_handles_unknown_type() {
416 let mut temp = NamedTempFile::new().unwrap();
417 writeln!(temp, r#"{{"type":"some-unknown-type","data":"whatever"}}"#).unwrap();
419 writeln!(
420 temp,
421 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
422 ).unwrap();
423 temp.flush().unwrap();
424
425 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
426 assert_eq!(convo.entries.len(), 1);
427 }
428
429 #[test]
430 fn test_read_conversation_metadata_empty_file() {
431 let mut temp = NamedTempFile::new().unwrap();
432 writeln!(temp).unwrap(); temp.flush().unwrap();
434
435 let meta = ConversationReader::read_conversation_metadata(temp.path()).unwrap();
436 assert_eq!(meta.message_count, 0);
437 assert!(meta.started_at.is_none());
438 assert!(meta.last_activity.is_none());
439 }
440
441 #[test]
442 fn test_read_from_offset_skips_metadata() {
443 let mut temp = NamedTempFile::new().unwrap();
444 writeln!(
446 temp,
447 r#"{{"type":"init","uuid":"","timestamp":"2024-01-01T00:00:00Z"}}"#
448 )
449 .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 (entries, _) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
457 assert_eq!(entries.len(), 1);
458 assert_eq!(entries[0].uuid, "u1");
459 }
460
461 #[test]
462 fn test_read_conversation_handles_blank_lines() {
463 let mut temp = NamedTempFile::new().unwrap();
464 writeln!(temp).unwrap(); writeln!(
466 temp,
467 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"user","content":"Hi"}}}}"#
468 ).unwrap();
469 writeln!(temp).unwrap(); temp.flush().unwrap();
471
472 let convo = ConversationReader::read_conversation(temp.path()).unwrap();
473 assert_eq!(convo.entries.len(), 1);
474 }
475}