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    /// Get the current file size for a conversation file.
215    /// Useful for checking if a file has grown since last read.
216    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        // Append another entry
332        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        // Entry with empty UUID (metadata) should be skipped
385        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        // Unknown type that isn't file-history-snapshot
418        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(); // Just blank lines
433        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        // Metadata entry with empty UUID
445        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(); // blank line
465        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(); // blank line
470        temp.flush().unwrap();
471
472        let convo = ConversationReader::read_conversation(temp.path()).unwrap();
473        assert_eq!(convo.entries.len(), 1);
474    }
475}