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) = entry.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
120                {
121                    if started_at.is_none() || Some(timestamp) < started_at {
122                        started_at = Some(timestamp);
123                    }
124                    if last_activity.is_none() || Some(timestamp) > last_activity {
125                        last_activity = Some(timestamp);
126                    }
127                }
128            }
129        }
130
131        Ok(crate::types::ConversationMetadata {
132            session_id,
133            project_path,
134            file_path: path.to_path_buf(),
135            message_count,
136            started_at,
137            last_activity,
138            first_user_message,
139        })
140    }
141
142    pub fn read_history<P: AsRef<Path>>(path: P) -> Result<Vec<HistoryEntry>> {
143        let path = path.as_ref();
144        if !path.exists() {
145            return Ok(Vec::new());
146        }
147
148        let file = File::open(path)?;
149        let reader = BufReader::new(file);
150        let mut history = Vec::new();
151
152        for line in reader.lines() {
153            let line = line?;
154            if line.trim().is_empty() {
155                continue;
156            }
157
158            match serde_json::from_str::<HistoryEntry>(&line) {
159                Ok(entry) => history.push(entry),
160                Err(e) => {
161                    eprintln!("Warning: Failed to parse history line: {}", e);
162                }
163            }
164        }
165
166        Ok(history)
167    }
168
169    /// Read conversation entries starting from a byte offset.
170    /// Returns the new entries and the new byte offset (end of file position).
171    ///
172    /// This is used for incremental reading - call with offset=0 initially,
173    /// then use the returned offset for subsequent calls to only read new entries.
174    pub fn read_from_offset<P: AsRef<Path>>(
175        path: P,
176        byte_offset: u64,
177    ) -> Result<(Vec<ConversationEntry>, u64)> {
178        let path = path.as_ref();
179        if !path.exists() {
180            return Err(ConvoError::ConversationNotFound(path.display().to_string()));
181        }
182
183        let mut file = File::open(path)?;
184        let file_len = file.metadata()?.len();
185
186        // If offset is beyond file length, file may have been truncated/rotated
187        // Return empty with current file length as new offset
188        if byte_offset > file_len {
189            return Ok((Vec::new(), file_len));
190        }
191
192        // Seek to the offset
193        file.seek(SeekFrom::Start(byte_offset))?;
194
195        let reader = BufReader::new(file);
196        let mut entries = Vec::new();
197        let mut current_offset = byte_offset;
198
199        for line in reader.lines() {
200            let line = line?;
201            // Track offset: line length + newline character
202            current_offset += line.len() as u64 + 1;
203
204            if line.trim().is_empty() {
205                continue;
206            }
207
208            // Try to parse as a conversation entry
209            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
210                // Only add entries with valid UUIDs (skip metadata entries)
211                if !entry.uuid.is_empty() {
212                    entries.push(entry);
213                }
214            }
215            // Silently skip unparseable lines (metadata, file-history-snapshot, etc.)
216        }
217
218        Ok((entries, current_offset))
219    }
220
221    /// Read the first session_id found in a conversation file.
222    ///
223    /// Scans at most 10 lines, returning the first non-empty `session_id`
224    /// field from a parseable `ConversationEntry`. Returns `None` if the
225    /// file doesn't exist, can't be read, or has no session_id in the
226    /// first 10 lines.
227    pub fn read_first_session_id<P: AsRef<Path>>(path: P) -> Option<String> {
228        let file = File::open(path.as_ref()).ok()?;
229        let reader = BufReader::new(file);
230
231        for line in reader.lines().take(10) {
232            let line = line.ok()?;
233            if line.trim().is_empty() {
234                continue;
235            }
236            if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line)
237                && let Some(sid) = &entry.session_id
238                && !sid.is_empty()
239            {
240                return Some(sid.clone());
241            }
242        }
243        None
244    }
245
246    /// Get the current file size for a conversation file.
247    /// Useful for checking if a file has grown since last read.
248    pub fn file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
249        let path = path.as_ref();
250        if !path.exists() {
251            return Err(ConvoError::ConversationNotFound(path.display().to_string()));
252        }
253        Ok(std::fs::metadata(path)?.len())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::fs;
261    use std::io::Write;
262    use tempfile::{NamedTempFile, TempDir};
263
264    #[test]
265    fn test_read_conversation() {
266        let mut temp = NamedTempFile::new().unwrap();
267        writeln!(
268            temp,
269            r#"{{"type":"user","uuid":"123","timestamp":"2024-01-01T00:00:00Z","sessionId":"test","message":{{"role":"user","content":"Hello"}}}}"#
270        )
271        .unwrap();
272        writeln!(
273            temp,
274            r#"{{"type":"assistant","uuid":"456","timestamp":"2024-01-01T00:00:01Z","sessionId":"test","message":{{"role":"assistant","content":"Hi there"}}}}"#
275        )
276        .unwrap();
277        temp.flush().unwrap();
278
279        let convo = ConversationReader::read_conversation(temp.path()).unwrap();
280        assert_eq!(convo.entries.len(), 2);
281        assert_eq!(convo.message_count(), 2);
282        assert_eq!(convo.user_messages().len(), 1);
283        assert_eq!(convo.assistant_messages().len(), 1);
284    }
285
286    #[test]
287    fn test_read_history() {
288        let mut temp = NamedTempFile::new().unwrap();
289        writeln!(
290            temp,
291            r#"{{"display":"Test query","pastedContents":{{}},"timestamp":1234567890,"project":"/test/project","sessionId":"session-123"}}"#
292        )
293        .unwrap();
294        temp.flush().unwrap();
295
296        let history = ConversationReader::read_history(temp.path()).unwrap();
297        assert_eq!(history.len(), 1);
298        assert_eq!(history[0].display, "Test query");
299        assert_eq!(history[0].project, Some("/test/project".to_string()));
300    }
301
302    #[test]
303    fn test_read_history_nonexistent() {
304        let history = ConversationReader::read_history("/nonexistent/file.jsonl").unwrap();
305        assert!(history.is_empty());
306    }
307
308    #[test]
309    fn test_read_conversation_metadata() {
310        // Layout matches `~/.claude/projects/<encoded>/<session>.jsonl`.
311        // The JSONL records a `cwd` that disagrees with the parent
312        // directory — the reader must ignore the JSONL value and derive
313        // `project_path` from the actual parent directory.
314        let temp = TempDir::new().unwrap();
315        let project_dir = temp.path().join("-Users-alex-Devel-myproject");
316        fs::create_dir_all(&project_dir).unwrap();
317        let file_path = project_dir.join("session-1.jsonl");
318        fs::write(
319            &file_path,
320            "\
321{\"type\":\"user\",\"uuid\":\"u1\",\"timestamp\":\"2024-01-01T00:00:00Z\",\"cwd\":\"/Users/ben/elsewhere\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}
322{\"type\":\"assistant\",\"uuid\":\"u2\",\"timestamp\":\"2024-01-01T00:01:00Z\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}
323",
324        )
325        .unwrap();
326
327        let meta = ConversationReader::read_conversation_metadata(&file_path).unwrap();
328        assert_eq!(meta.message_count, 2);
329        assert_eq!(meta.session_id, "session-1");
330        assert_eq!(meta.project_path, "/Users/alex/Devel/myproject");
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}