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) if !entry.uuid.is_empty() => {
36                    conversation.add_entry(entry);
37                }
38                Ok(_) | Err(_) => {
39                    // Headerless / metadata lines (ai-title, last-prompt,
40                    // queue-operation, permission-mode, file-history-snapshot,
41                    // etc.) are preserved verbatim so the projector can
42                    // re-emit them on roundtrip.
43                    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            // Try to parse as ConversationEntry, skip if it fails (likely a metadata entry)
89            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
90                // Only process entries with valid UUIDs
91                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                    // First user prompt with actual text (skip tool-result-only
103                    // user entries, which `Message::text()` collapses to "").
104                    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    /// Read conversation entries starting from a byte offset.
169    /// Returns the new entries and the new byte offset (end of file position).
170    ///
171    /// This is used for incremental reading - call with offset=0 initially,
172    /// then use the returned offset for subsequent calls to only read new entries.
173    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 offset is beyond file length, file may have been truncated/rotated
186        // Return empty with current file length as new offset
187        if byte_offset > file_len {
188            return Ok((Vec::new(), file_len));
189        }
190
191        // Seek to the offset
192        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            // Track offset: line length + newline character
201            current_offset += line.len() as u64 + 1;
202
203            if line.trim().is_empty() {
204                continue;
205            }
206
207            // Try to parse as a conversation entry
208            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
209                // Only add entries with valid UUIDs (skip metadata entries)
210                if !entry.uuid.is_empty() {
211                    entries.push(entry);
212                }
213            }
214            // Silently skip unparseable lines (metadata, file-history-snapshot, etc.)
215        }
216
217        Ok((entries, current_offset))
218    }
219
220    /// Read the first session_id found in a conversation file.
221    ///
222    /// Scans at most 10 lines, returning the first non-empty `session_id`
223    /// field from a parseable `ConversationEntry`. Returns `None` if the
224    /// file doesn't exist, can't be read, or has no session_id in the
225    /// first 10 lines.
226    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    /// Get the current file size for a conversation file.
246    /// Useful for checking if a file has grown since last read.
247    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        // Append another entry
363        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        // Entry with empty UUID (metadata) should be skipped
416        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        // Unknown type that isn't file-history-snapshot
449        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(); // Just blank lines
464        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        // Metadata entry with empty UUID
476        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(); // blank line
530        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(); // blank line
535        temp.flush().unwrap();
536
537        let convo = ConversationReader::read_conversation(temp.path()).unwrap();
538        assert_eq!(convo.entries.len(), 1);
539    }
540}