Skip to main content

toolpath_claude/
writer.rs

1use crate::error::Result;
2use crate::types::Conversation;
3use std::io::Write;
4
5pub struct ConversationWriter;
6
7impl ConversationWriter {
8    /// Writes `conv` as Claude Code session-file JSONL: preamble
9    /// lines, then entries, one JSON value per line, each line
10    /// newline-terminated.
11    pub fn write_conversation<W: Write>(conv: &Conversation, mut w: W) -> Result<()> {
12        // Trailing newline matters: Claude Code appends to this file on resume,
13        // and without it the first appended entry lands on the last line.
14        for raw in &conv.preamble {
15            serde_json::to_writer(&mut w, raw)?;
16            w.write_all(b"\n")?;
17        }
18        for entry in &conv.entries {
19            serde_json::to_writer(&mut w, entry)?;
20            w.write_all(b"\n")?;
21        }
22        Ok(())
23    }
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use crate::reader::ConversationReader;
30    use crate::types::ConversationEntry;
31    use serde_json::Value;
32
33    fn conversation() -> Conversation {
34        let mut convo = Conversation::new("test-session".to_string());
35        convo
36            .preamble
37            .push(serde_json::json!({"type": "summary", "summary": "s"}));
38        let entries = [
39            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
40            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
41        ];
42        for entry_json in entries {
43            let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
44            convo.add_entry(entry);
45        }
46        convo
47    }
48
49    #[test]
50    fn one_record_per_line_newline_terminated() {
51        let convo = conversation();
52
53        let mut buf = Vec::new();
54        ConversationWriter::write_conversation(&convo, &mut buf).unwrap();
55        let out = String::from_utf8(buf).unwrap();
56
57        assert!(out.ends_with('\n'));
58        assert!(!out.ends_with("\n\n"));
59        let lines: Vec<&str> = out.lines().collect();
60        assert_eq!(lines.len(), 3);
61        assert!(lines[0].contains("\"summary\""));
62        for line in &lines {
63            serde_json::from_str::<Value>(line).unwrap();
64        }
65    }
66
67    #[test]
68    fn round_trips_through_the_reader() {
69        let convo = conversation();
70        let file = tempfile::NamedTempFile::new().unwrap();
71        ConversationWriter::write_conversation(&convo, file.as_file()).unwrap();
72
73        let back = ConversationReader::read_conversation(file.path()).unwrap();
74        let entries = |c: &Conversation| serde_json::to_value(&c.entries).unwrap();
75        assert_eq!(entries(&back), entries(&convo));
76        assert_eq!(back.preamble, convo.preamble);
77    }
78}