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