Skip to main content

toolpath_claude/
reader.rs

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            // Try to parse as a conversation entry
34            match serde_json::from_str::<ConversationEntry>(&line) {
35                Ok(entry) => {
36                    // Only add entries with valid UUIDs (skip metadata entries)
37                    if !entry.uuid.is_empty() {
38                        conversation.add_entry(entry);
39                    }
40                }
41                Err(_) => {
42                    // Try to parse as a generic JSON value to check the type
43                    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                        // Known metadata types we can safely ignore
47                        if entry_type == "file-history-snapshot" {
48                            // Silently skip file snapshots
49                            continue;
50                        }
51                    }
52
53                    // Only warn about truly unexpected parse failures
54                    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            // Try to parse as ConversationEntry, skip if it fails (likely a metadata entry)
97            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
98                // Only process entries with valid UUIDs
99                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    /// Read conversation entries starting from a byte offset.
163    /// Returns the new entries and the new byte offset (end of file position).
164    ///
165    /// This is used for incremental reading - call with offset=0 initially,
166    /// then use the returned offset for subsequent calls to only read new entries.
167    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 offset is beyond file length, file may have been truncated/rotated
180        // Return empty with current file length as new offset
181        if byte_offset > file_len {
182            return Ok((Vec::new(), file_len));
183        }
184
185        // Seek to the offset
186        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            // Track offset: line length + newline character
195            current_offset += line.len() as u64 + 1;
196
197            if line.trim().is_empty() {
198                continue;
199            }
200
201            // Try to parse as a conversation entry
202            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
203                // Only add entries with valid UUIDs (skip metadata entries)
204                if !entry.uuid.is_empty() {
205                    entries.push(entry);
206                }
207            }
208            // Silently skip unparseable lines (metadata, file-history-snapshot, etc.)
209        }
210
211        Ok((entries, current_offset))
212    }
213
214    /// Read the first session_id found in a conversation file.
215    ///
216    /// Scans at most 10 lines, returning the first non-empty `session_id`
217    /// field from a parseable `ConversationEntry`. Returns `None` if the
218    /// file doesn't exist, can't be read, or has no session_id in the
219    /// first 10 lines.
220    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    /// Get the current file size for a conversation file.
240    /// Useful for checking if a file has grown since last read.
241    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        // Append another entry
357        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        // Entry with empty UUID (metadata) should be skipped
410        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        // Unknown type that isn't file-history-snapshot
443        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(); // Just blank lines
458        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        // Metadata entry with empty UUID
470        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(); // blank line
524        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(); // blank line
529        temp.flush().unwrap();
530
531        let convo = ConversationReader::read_conversation(temp.path()).unwrap();
532        assert_eq!(convo.entries.len(), 1);
533    }
534}