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::agent_harness::AgentHarness;
8use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
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).map_err(|e| format!("Could not write markdown export: {e}"))
139}
140
141fn export_html(entries: &[Entry], output_path: &Path) -> Result<(), String> {
142    let mut html = String::from(
143        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
164    for e in entries {
165        if let Entry::Message(me) = e {
166            match &me.message {
167                rpi_agent::AgentMessage::User(u) => {
168                    let text = user_message_text(u);
169                    html.push_str(&format!(
170                        r#"<div class="message user">
171    <div class="role">User</div>
172    <div>{}</div>
173</div>"#,
174                        escape_html(&text)
175                    ));
176                }
177                rpi_agent::AgentMessage::Assistant(a) => {
178                    let text = assistant_text(a);
179                    if !text.is_empty() {
180                        html.push_str(&format!(
181                            r#"<div class="message assistant">
182    <div class="role">Assistant</div>
183    <div>{}</div>
184</div>"#,
185                            escape_html(&text)
186                        ));
187                    }
188                }
189                _ => {}
190            }
191        }
192    }
193
194    html.push_str("\n</body>\n</html>");
195
196    std::fs::write(output_path, html).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).map_err(|e| format!("Could not write JSONL export: {e}"))
208}
209
210fn user_message_text(user: &rpi_ai::types::UserMessage) -> String {
211    match &user.content {
212        rpi_ai::types::UserContent::Text(s) => s.clone(),
213        rpi_ai::types::UserContent::Blocks(blocks) => blocks
214            .iter()
215            .filter_map(|c| match c {
216                rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
217                rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
218                _ => None,
219            })
220            .collect::<Vec<_>>()
221            .join("\n"),
222    }
223}
224
225fn assistant_text(assistant: &rpi_ai::types::AssistantMessage) -> String {
226    assistant
227        .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) => {
232                Some(format!("<thinking>{}</thinking>", t.thinking))
233            }
234            rpi_ai::types::Content::ToolCall(tc) => Some(format!("[Tool Call: {}]", tc.name)),
235            rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
236        })
237        .collect::<Vec<_>>()
238        .join("\n\n")
239}
240
241fn escape_html(text: &str) -> String {
242    text.replace('&', "&amp;")
243        .replace('<', "&lt;")
244        .replace('>', "&gt;")
245        .replace('"', "&quot;")
246        .replace('\'', "&#39;")
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use rpi_harness::session::jsonl::codec::{encode_header, encode_mutation};
253    use rpi_harness::session::jsonl::{HeaderKind, JsonlV4Header};
254    use rpi_harness::session::types::{Entry, EntryBase, MessageEntry, SessionMutation};
255
256    #[test]
257    fn test_escape_html() {
258        assert_eq!(
259            escape_html("<script>alert('xss')</script>"),
260            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
261        );
262        assert_eq!(escape_html("Hello & World"), "Hello &amp; World");
263    }
264
265    /// Build a real JSONL v4 session file containing one user + one assistant
266    /// message, then export it to each format via the `--export` code path.
267    #[test]
268    fn export_file_renders_a_real_jsonl_session() {
269        let dir = tempfile::tempdir().unwrap();
270        let session_path = dir.path().join("session.jsonl");
271
272        let header = JsonlV4Header {
273            kind: HeaderKind,
274            version: 4,
275            id: "s-export".to_string(),
276            created_at: 0,
277            cwd: "/tmp".to_string(),
278            parent_session_id: None,
279            legacy_parent_session_path: None,
280            metadata: None,
281        };
282
283        let mut lines = vec![encode_header(&header)];
284        for (seq, message) in [
285            (
286                1u64,
287                rpi_agent::message::AgentMessage::User(rpi_ai::types::UserMessage::new(
288                    rpi_ai::types::UserContent::Text("hello <world>".to_string()),
289                    0,
290                )),
291            ),
292            (
293                2u64,
294                rpi_agent::message::AgentMessage::Assistant(Box::new({
295                    let mut a = rpi_ai::types::AssistantMessage::empty(
296                        rpi_ai::Api::AnthropicMessages,
297                        "anthropic",
298                        "m",
299                        0,
300                    );
301                    a.content = vec![rpi_ai::types::Content::Text(rpi_ai::types::TextContent {
302                        kind: rpi_ai::types::TextContentType,
303                        text: "hi there".to_string(),
304                        text_signature: None,
305                    })];
306                    a
307                })),
308            ),
309        ] {
310            let entry = Entry::Message(MessageEntry {
311                base: EntryBase {
312                    entry_type: "message".to_string(),
313                    id: format!("e{seq}"),
314                    seq,
315                    parent_id: None,
316                    timestamp: 0,
317                },
318                message,
319                terminate: None,
320            });
321            lines.push(encode_mutation(&SessionMutation::Entry {
322                seq,
323                timestamp: 0,
324                lane: Some("main".to_string()),
325                entry,
326            }));
327        }
328        std::fs::write(&session_path, lines.concat()).unwrap();
329
330        // Markdown
331        let md_path = dir.path().join("out.md");
332        export_file(&session_path, &md_path).unwrap();
333        let md = std::fs::read_to_string(&md_path).unwrap();
334        assert!(md.contains("hello <world>"), "{md}");
335        assert!(md.contains("hi there"), "{md}");
336
337        // HTML escapes the angle brackets in the user text.
338        let html_path = dir.path().join("out.html");
339        export_file(&session_path, &html_path).unwrap();
340        let html = std::fs::read_to_string(&html_path).unwrap();
341        assert!(html.contains("&lt;world&gt;"), "{html}");
342        assert!(html.contains("hi there"), "{html}");
343        assert!(html.trim_end().ends_with("</html>"), "{html}");
344
345        // JSONL re-emits the parsed entries (not the original wrapper lines).
346        let jsonl_path = dir.path().join("out.jsonl");
347        export_file(&session_path, &jsonl_path).unwrap();
348        let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
349        let parsed: Vec<Entry> = jsonl
350            .lines()
351            .filter(|l| !l.trim().is_empty())
352            .map(|l| serde_json::from_str(l).unwrap())
353            .collect();
354        assert_eq!(parsed.len(), 2);
355    }
356
357    #[test]
358    fn export_file_rejects_a_non_session_file() {
359        let dir = tempfile::tempdir().unwrap();
360        let bogus = dir.path().join("not-a-session.jsonl");
361        std::fs::write(&bogus, "{\"type\":\"message\"}\n").unwrap();
362        let out = dir.path().join("out.md");
363        let err = export_file(&bogus, &out).unwrap_err();
364        assert!(err.contains("not a JSONL v4 session file"), "{err}");
365    }
366}