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