Skip to main content

rpi_cli/
export.rs

1//! Session export functionality.
2//!
3//! Supports multiple export formats: Markdown (current), HTML, and JSONL.
4
5use std::path::Path;
6
7use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
8use rpi_harness::agent_harness::AgentHarness;
9
10/// Export format options.
11#[derive(Debug, Clone, PartialEq)]
12pub enum ExportFormat {
13    Markdown,
14    Html,
15    Jsonl,
16}
17
18/// Export a JSONL session file to the specified format.
19///
20/// The input is a real JSONL v4 session file (header line + one mutation per
21/// line), so it is parsed with the harness codec rather than assuming raw
22/// `Entry` JSON. Only entry mutations contribute to the rendered transcript;
23/// lane/fact/record lines are skipped (they are not conversational content).
24pub fn export_file(input: &Path, output: &Path) -> Result<(), String> {
25    // Determine format from output extension
26    let format = if output
27        .extension()
28        .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
29    {
30        ExportFormat::Html
31    } else if output
32        .extension()
33        .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
34    {
35        ExportFormat::Jsonl
36    } else {
37        ExportFormat::Markdown
38    };
39
40    let entries = read_jsonl_session_entries(input)?;
41
42    match format {
43        ExportFormat::Markdown => export_markdown(&entries, output),
44        ExportFormat::Html => export_html(&entries, output),
45        ExportFormat::Jsonl => export_jsonl(&entries, output),
46    }
47}
48
49/// Parse a JSONL v4 session file into its entry list (oldest first).
50fn read_jsonl_session_entries(input: &Path) -> Result<Vec<Entry>, String> {
51    use rpi_harness::session::jsonl::parse_mutation;
52    use rpi_harness::session::types::SessionMutation;
53
54    let content = std::fs::read_to_string(input)
55        .map_err(|e| format!("Could not read input file {}: {e}", input.display()))?;
56
57    let mut entries = Vec::new();
58    let mut saw_header = false;
59    for (index, line) in content.lines().enumerate() {
60        let line = line.trim();
61        if line.is_empty() {
62            continue;
63        }
64        if !saw_header {
65            // A valid session file starts with the v4 header line. Validate it
66            // so a plain JSON/old file fails with a clear message.
67            rpi_harness::session::jsonl::codec::parse_header(line).map_err(|e| {
68                format!(
69                    "{} is not a JSONL v4 session file (line {}): {e}",
70                    input.display(),
71                    index + 1
72                )
73            })?;
74            saw_header = true;
75            continue;
76        }
77        let mutation = parse_mutation(line).map_err(|e| {
78            format!(
79                "could not parse mutation at {}:{}: {e}",
80                input.display(),
81                index + 1
82            )
83        })?;
84        if let SessionMutation::Entry { entry, .. } = mutation {
85            entries.push(entry);
86        }
87    }
88    if !saw_header {
89        return Err(format!("{} is empty", input.display()));
90    }
91    Ok(entries)
92}
93
94/// Export the session to the specified format.
95pub async fn export_session(
96    harness: &AgentHarness,
97    format: ExportFormat,
98    output_path: &Path,
99) -> Result<(), String> {
100    let tree = harness.session().view("main");
101    let entries = tree
102        .find_entries(&EntryQuery {
103            entry_type: None,
104            custom_type: None,
105            order: Some(EntryOrder::OldestFirst),
106            limit: None,
107            cursor: None,
108        })
109        .await
110        .map_err(|e| format!("Could not read session: {e}"))?;
111
112    match format {
113        ExportFormat::Markdown => export_markdown(&entries, output_path),
114        ExportFormat::Html => export_html(&entries, output_path),
115        ExportFormat::Jsonl => export_jsonl(&entries, output_path),
116    }
117}
118
119fn export_markdown(entries: &[Entry], output_path: &Path) -> Result<(), String> {
120    let mut md = String::from("# Session\n\n");
121    for e in entries {
122        if let Entry::Message(me) = e {
123            match &me.message {
124                rpi_agent::AgentMessage::User(u) => {
125                    let text = user_message_text(u);
126                    md.push_str(&format!("## User\n\n{}\n\n", text));
127                }
128                rpi_agent::AgentMessage::Assistant(a) => {
129                    let text = assistant_text(a);
130                    if !text.is_empty() {
131                        md.push_str(&format!("## Assistant\n\n{}\n\n", text));
132                    }
133                }
134                _ => {}
135            }
136        }
137    }
138    std::fs::write(output_path, md)
139        .map_err(|e| format!("Could not write markdown export: {e}"))
140}
141
142fn export_html(entries: &[Entry], output_path: &Path) -> Result<(), String> {
143    let mut html = String::from(r#"<!DOCTYPE html>
144<html lang="en">
145<head>
146    <meta charset="UTF-8">
147    <meta name="viewport" content="width=device-width, initial-scale=1.0">
148    <title>Session Export</title>
149    <style>
150        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }
151        .message { margin-bottom: 2rem; padding: 1rem; border-radius: 8px; }
152        .user { background-color: #f0f9ff; border-left: 4px solid #3b82f6; }
153        .assistant { background-color: #f3f4f6; border-left: 4px solid #6b7280; }
154        .role { font-weight: bold; margin-bottom: 0.5rem; color: #1f2937; }
155        pre { background-color: #1e293b; color: #f8fafc; padding: 1rem; border-radius: 6px; overflow-x: auto; }
156        code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; }
157    </style>
158</head>
159<body>
160    <h1>Session Export</h1>
161"#);
162
163    for e in entries {
164        if let Entry::Message(me) = e {
165            match &me.message {
166                rpi_agent::AgentMessage::User(u) => {
167                    let text = user_message_text(u);
168                    html.push_str(&format!(
169                        r#"<div class="message user">
170    <div class="role">User</div>
171    <div>{}</div>
172</div>"#,
173                        escape_html(&text)
174                    ));
175                }
176                rpi_agent::AgentMessage::Assistant(a) => {
177                    let text = assistant_text(a);
178                    if !text.is_empty() {
179                        html.push_str(&format!(
180                            r#"<div class="message assistant">
181    <div class="role">Assistant</div>
182    <div>{}</div>
183</div>"#,
184                            escape_html(&text)
185                        ));
186                    }
187                }
188                _ => {}
189            }
190        }
191    }
192
193    html.push_str("\n</body>\n</html>");
194
195    std::fs::write(output_path, html)
196        .map_err(|e| format!("Could not write HTML export: {e}"))
197}
198
199fn export_jsonl(entries: &[Entry], output_path: &Path) -> Result<(), String> {
200    let mut jsonl = String::new();
201    for e in entries {
202        let json = serde_json::to_string(e)
203            .map_err(|e| format!("Could not serialize entry to JSON: {e}"))?;
204        jsonl.push_str(&json);
205        jsonl.push('\n');
206    }
207    std::fs::write(output_path, jsonl)
208        .map_err(|e| format!("Could not write JSONL export: {e}"))
209}
210
211fn user_message_text(user: &rpi_ai::types::UserMessage) -> String {
212    match &user.content {
213        rpi_ai::types::UserContent::Text(s) => s.clone(),
214        rpi_ai::types::UserContent::Blocks(blocks) => blocks
215            .iter()
216            .filter_map(|c| match c {
217                rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
218                rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
219                _ => None,
220            })
221            .collect::<Vec<_>>()
222            .join("\n"),
223    }
224}
225
226fn assistant_text(assistant: &rpi_ai::types::AssistantMessage) -> String {
227    assistant.content
228        .iter()
229        .filter_map(|c| match c {
230            rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
231            rpi_ai::types::Content::Thinking(t) => Some(format!("<thinking>{}</thinking>", t.thinking)),
232            rpi_ai::types::Content::ToolCall(tc) => Some(format!("[Tool Call: {}]", tc.name)),
233            rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
234        })
235        .collect::<Vec<_>>()
236        .join("\n\n")
237}
238
239fn escape_html(text: &str) -> String {
240    text.replace('&', "&amp;")
241        .replace('<', "&lt;")
242        .replace('>', "&gt;")
243        .replace('"', "&quot;")
244        .replace('\'', "&#39;")
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use rpi_harness::session::jsonl::codec::{encode_header, encode_mutation};
251    use rpi_harness::session::jsonl::{HeaderKind, JsonlV4Header};
252    use rpi_harness::session::types::{Entry, EntryBase, MessageEntry, SessionMutation};
253
254    #[test]
255    fn test_escape_html() {
256        assert_eq!(
257            escape_html("<script>alert('xss')</script>"),
258            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
259        );
260        assert_eq!(escape_html("Hello & World"), "Hello &amp; World");
261    }
262
263    /// Build a real JSONL v4 session file containing one user + one assistant
264    /// message, then export it to each format via the `--export` code path.
265    #[test]
266    fn export_file_renders_a_real_jsonl_session() {
267        let dir = tempfile::tempdir().unwrap();
268        let session_path = dir.path().join("session.jsonl");
269
270        let header = JsonlV4Header {
271            kind: HeaderKind,
272            version: 4,
273            id: "s-export".to_string(),
274            created_at: 0,
275            cwd: "/tmp".to_string(),
276            parent_session_id: None,
277            legacy_parent_session_path: None,
278            metadata: None,
279        };
280
281        let mut lines = vec![encode_header(&header)];
282        for (seq, message) in [
283            (
284                1u64,
285                rpi_agent::message::AgentMessage::User(rpi_ai::types::UserMessage::new(
286                    rpi_ai::types::UserContent::Text("hello <world>".to_string()),
287                    0,
288                )),
289            ),
290            (
291                2u64,
292                rpi_agent::message::AgentMessage::Assistant(Box::new({
293                    let mut a = rpi_ai::types::AssistantMessage::empty(
294                        rpi_ai::Api::AnthropicMessages,
295                        "anthropic",
296                        "m",
297                        0,
298                    );
299                    a.content = vec![rpi_ai::types::Content::Text(rpi_ai::types::TextContent {
300                        kind: rpi_ai::types::TextContentType,
301                        text: "hi there".to_string(),
302                        text_signature: None,
303                    })];
304                    a
305                })),
306            ),
307        ] {
308            let entry = Entry::Message(MessageEntry {
309                base: EntryBase {
310                    entry_type: "message".to_string(),
311                    id: format!("e{seq}"),
312                    seq,
313                    parent_id: None,
314                    timestamp: 0,
315                },
316                message,
317                terminate: None,
318            });
319            lines.push(encode_mutation(&SessionMutation::Entry {
320                seq,
321                timestamp: 0,
322                lane: Some("main".to_string()),
323                entry,
324            }));
325        }
326        std::fs::write(&session_path, lines.concat()).unwrap();
327
328        // Markdown
329        let md_path = dir.path().join("out.md");
330        export_file(&session_path, &md_path).unwrap();
331        let md = std::fs::read_to_string(&md_path).unwrap();
332        assert!(md.contains("hello <world>"), "{md}");
333        assert!(md.contains("hi there"), "{md}");
334
335        // HTML escapes the angle brackets in the user text.
336        let html_path = dir.path().join("out.html");
337        export_file(&session_path, &html_path).unwrap();
338        let html = std::fs::read_to_string(&html_path).unwrap();
339        assert!(html.contains("&lt;world&gt;"), "{html}");
340        assert!(html.contains("hi there"), "{html}");
341        assert!(html.trim_end().ends_with("</html>"), "{html}");
342
343        // JSONL re-emits the parsed entries (not the original wrapper lines).
344        let jsonl_path = dir.path().join("out.jsonl");
345        export_file(&session_path, &jsonl_path).unwrap();
346        let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
347        let parsed: Vec<Entry> = jsonl
348            .lines()
349            .filter(|l| !l.trim().is_empty())
350            .map(|l| serde_json::from_str(l).unwrap())
351            .collect();
352        assert_eq!(parsed.len(), 2);
353    }
354
355    #[test]
356    fn export_file_rejects_a_non_session_file() {
357        let dir = tempfile::tempdir().unwrap();
358        let bogus = dir.path().join("not-a-session.jsonl");
359        std::fs::write(&bogus, "{\"type\":\"message\"}\n").unwrap();
360        let out = dir.path().join("out.md");
361        let err = export_file(&bogus, &out).unwrap_err();
362        assert!(err.contains("not a JSONL v4 session file"), "{err}");
363    }
364}