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    // `--export` reads a finished session file: every message in it was accepted
43    // by storage, so there is nothing to recover here. The recovery copies only
44    // exist for a live harness (see `export_session`).
45    let unsaved: &[rpi_harness::agent_harness::UnsavedMessage] = &[];
46
47    match format {
48        ExportFormat::Markdown => export_markdown(&entries, unsaved, output),
49        ExportFormat::Html => export_html(&entries, unsaved, output),
50        ExportFormat::Jsonl => export_jsonl(&entries, unsaved, output),
51    }
52}
53
54/// Parse a JSONL v4 session file into its entry list (oldest first).
55fn read_jsonl_session_entries(input: &Path) -> Result<Vec<Entry>, String> {
56    use rpi_harness::session::jsonl::parse_mutation;
57    use rpi_harness::session::types::SessionMutation;
58
59    let content = std::fs::read_to_string(input)
60        .map_err(|e| format!("Could not read input file {}: {e}", input.display()))?;
61
62    let mut entries = Vec::new();
63    let mut saw_header = false;
64    for (index, line) in content.lines().enumerate() {
65        let line = line.trim();
66        if line.is_empty() {
67            continue;
68        }
69        if !saw_header {
70            // A valid session file starts with the v4 header line. Validate it
71            // so a plain JSON/old file fails with a clear message.
72            rpi_harness::session::jsonl::codec::parse_header(line).map_err(|e| {
73                format!(
74                    "{} is not a JSONL v4 session file (line {}): {e}",
75                    input.display(),
76                    index + 1
77                )
78            })?;
79            saw_header = true;
80            continue;
81        }
82        let mutation = parse_mutation(line).map_err(|e| {
83            format!(
84                "could not parse mutation at {}:{}: {e}",
85                input.display(),
86                index + 1
87            )
88        })?;
89        if let SessionMutation::Entry { entry, .. } = mutation {
90            entries.push(entry);
91        }
92    }
93    if !saw_header {
94        return Err(format!("{} is empty", input.display()));
95    }
96    Ok(entries)
97}
98
99/// Export the session to the specified format.
100pub async fn export_session(
101    harness: &AgentHarness,
102    format: ExportFormat,
103    output_path: &Path,
104) -> Result<(), String> {
105    let tree = harness.session().view("main");
106    let entries = tree
107        .find_entries(&EntryQuery {
108            entry_type: None,
109            custom_type: None,
110            order: Some(EntryOrder::OldestFirst),
111            limit: None,
112            cursor: None,
113        })
114        .await
115        .map_err(|e| format!("Could not read session: {e}"))?;
116
117    // A refused append never becomes an entry, so the session view above cannot
118    // contain it. Append the harness's recovery copies: the warning tells the user
119    // to export before exiting, and this is what makes that promise true.
120    let unsaved = harness.unsaved_messages();
121
122    match format {
123        ExportFormat::Markdown => export_markdown(&entries, &unsaved, output_path),
124        ExportFormat::Html => export_html(&entries, &unsaved, output_path),
125        ExportFormat::Jsonl => export_jsonl(&entries, &unsaved, output_path),
126    }
127}
128
129fn export_markdown(
130    entries: &[Entry],
131    unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
132    output_path: &Path,
133) -> Result<(), String> {
134    let mut md = String::from("# Session\n\n");
135    for e in entries {
136        if let Entry::Message(me) = e {
137            match &me.message {
138                rpi_agent::AgentMessage::User(u) => {
139                    let text = user_message_text(u);
140                    md.push_str(&format!("## User\n\n{}\n\n", text));
141                }
142                rpi_agent::AgentMessage::Assistant(a) => {
143                    let text = assistant_text(a);
144                    if !text.is_empty() {
145                        md.push_str(&format!("## Assistant\n\n{}\n\n", text));
146                    }
147                }
148                _ => {}
149            }
150        }
151    }
152    // Messages the session never accepted, appended last: they are the most
153    // recent turns, and a refused write is exactly the content that exists
154    // nowhere else.
155    if !unsaved.is_empty() {
156        md.push_str("# Not saved\n\n");
157        md.push_str(
158            "These messages are missing from the session log because saving them was \
159             refused. They are reproduced here so this export is the complete \
160             conversation.\n\n",
161        );
162        for failure in unsaved {
163            append_unsaved_markdown(&mut md, &failure.message);
164        }
165    }
166    std::fs::write(output_path, md).map_err(|e| format!("Could not write markdown export: {e}"))
167}
168
169/// Render one recovery-copied message into a markdown export.
170fn append_unsaved_markdown(md: &mut String, message: &rpi_agent::AgentMessage) {
171    match message {
172        rpi_agent::AgentMessage::User(u) => {
173            md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
174        }
175        rpi_agent::AgentMessage::Assistant(a) => {
176            let text = assistant_text(a);
177            if !text.is_empty() {
178                md.push_str(&format!("## Assistant\n\n{text}\n\n"));
179            }
180        }
181        other => {
182            md.push_str(&format!("## Message\n\n```text\n{other:?}\n```\n\n"));
183        }
184    }
185}
186
187fn export_html(
188    entries: &[Entry],
189    unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
190    output_path: &Path,
191) -> Result<(), String> {
192    let mut html = String::from(
193        r#"<!DOCTYPE html>
194<html lang="en">
195<head>
196    <meta charset="UTF-8">
197    <meta name="viewport" content="width=device-width, initial-scale=1.0">
198    <title>Session Export</title>
199    <style>
200        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }
201        .message { margin-bottom: 2rem; padding: 1rem; border-radius: 8px; }
202        .user { background-color: #f0f9ff; border-left: 4px solid #3b82f6; }
203        .assistant { background-color: #f3f4f6; border-left: 4px solid #6b7280; }
204        .role { font-weight: bold; margin-bottom: 0.5rem; color: #1f2937; }
205        pre { background-color: #1e293b; color: #f8fafc; padding: 1rem; border-radius: 6px; overflow-x: auto; }
206        code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; }
207    </style>
208</head>
209<body>
210    <h1>Session Export</h1>
211"#,
212    );
213
214    for e in entries {
215        if let Entry::Message(me) = e {
216            match &me.message {
217                rpi_agent::AgentMessage::User(u) => {
218                    let text = user_message_text(u);
219                    html.push_str(&format!(
220                        r#"<div class="message user">
221    <div class="role">User</div>
222    <div>{}</div>
223</div>"#,
224                        escape_html(&text)
225                    ));
226                }
227                rpi_agent::AgentMessage::Assistant(a) => {
228                    let text = assistant_text(a);
229                    if !text.is_empty() {
230                        html.push_str(&format!(
231                            r#"<div class="message assistant">
232    <div class="role">Assistant</div>
233    <div>{}</div>
234</div>"#,
235                            escape_html(&text)
236                        ));
237                    }
238                }
239                _ => {}
240            }
241        }
242    }
243
244    if !unsaved.is_empty() {
245        html.push_str(
246            r#"<h2>Not saved</h2>
247<p>These messages are missing from the session log because saving them was refused. They are reproduced here so this export is the complete conversation.</p>
248"#,
249        );
250        for failure in unsaved {
251            match &failure.message {
252                rpi_agent::AgentMessage::User(u) => {
253                    html.push_str(&format!(
254                        r#"<div class="message user">
255    <div class="role">User</div>
256    <div>{}</div>
257</div>"#,
258                        escape_html(&user_message_text(u))
259                    ));
260                }
261                rpi_agent::AgentMessage::Assistant(a) => {
262                    let text = assistant_text(a);
263                    if !text.is_empty() {
264                        html.push_str(&format!(
265                            r#"<div class="message assistant">
266    <div class="role">Assistant</div>
267    <div>{}</div>
268</div>"#,
269                            escape_html(&text)
270                        ));
271                    }
272                }
273                other => {
274                    html.push_str(&format!(
275                        r#"<div class="message assistant">
276    <div class="role">Message</div>
277    <pre>{}</pre>
278</div>"#,
279                        escape_html(&format!("{other:?}"))
280                    ));
281                }
282            }
283        }
284    }
285
286    html.push_str("\n</body>\n</html>");
287
288    std::fs::write(output_path, html).map_err(|e| format!("Could not write HTML export: {e}"))
289}
290
291fn export_jsonl(
292    entries: &[Entry],
293    unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
294    output_path: &Path,
295) -> Result<(), String> {
296    let mut jsonl = String::new();
297    for e in entries {
298        let json = serde_json::to_string(e)
299            .map_err(|e| format!("Could not serialize entry to JSON: {e}"))?;
300        jsonl.push_str(&json);
301        jsonl.push('\n');
302    }
303    // A JSONL export is a machine-readable stream, so the recovery copies are
304    // written as messages with an explicit marker rather than a comment line:
305    // the consumer can then tell an unsaved turn from a persisted one.
306    for failure in unsaved {
307        let json = serde_json::to_string(&serde_json::json!({
308            "type": "unsaved_message",
309            "reason": failure.what,
310            "message": failure.message,
311        }))
312        .map_err(|e| format!("Could not serialize unsaved message to JSON: {e}"))?;
313        jsonl.push_str(&json);
314        jsonl.push('\n');
315    }
316    std::fs::write(output_path, jsonl).map_err(|e| format!("Could not write JSONL export: {e}"))
317}
318
319fn user_message_text(user: &rpi_ai::types::UserMessage) -> String {
320    match &user.content {
321        rpi_ai::types::UserContent::Text(s) => s.clone(),
322        rpi_ai::types::UserContent::Blocks(blocks) => blocks
323            .iter()
324            .filter_map(|c| match c {
325                rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
326                rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
327                _ => None,
328            })
329            .collect::<Vec<_>>()
330            .join("\n"),
331    }
332}
333
334fn assistant_text(assistant: &rpi_ai::types::AssistantMessage) -> String {
335    assistant
336        .content
337        .iter()
338        .filter_map(|c| match c {
339            rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
340            rpi_ai::types::Content::Thinking(t) => {
341                Some(format!("<thinking>{}</thinking>", t.thinking))
342            }
343            rpi_ai::types::Content::ToolCall(tc) => Some(format!("[Tool Call: {}]", tc.name)),
344            rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
345        })
346        .collect::<Vec<_>>()
347        .join("\n\n")
348}
349
350fn escape_html(text: &str) -> String {
351    text.replace('&', "&amp;")
352        .replace('<', "&lt;")
353        .replace('>', "&gt;")
354        .replace('"', "&quot;")
355        .replace('\'', "&#39;")
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use rpi_harness::session::jsonl::codec::{encode_header, encode_mutation};
362    use rpi_harness::session::jsonl::{HeaderKind, JsonlV4Header};
363    use rpi_harness::session::types::{Entry, EntryBase, MessageEntry, SessionMutation};
364
365    #[test]
366    fn test_escape_html() {
367        assert_eq!(
368            escape_html("<script>alert('xss')</script>"),
369            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
370        );
371        assert_eq!(escape_html("Hello & World"), "Hello &amp; World");
372    }
373
374    /// Build a real JSONL v4 session file containing one user + one assistant
375    /// message, then export it to each format via the `--export` code path.
376    #[test]
377    fn export_file_renders_a_real_jsonl_session() {
378        let dir = tempfile::tempdir().unwrap();
379        let session_path = dir.path().join("session.jsonl");
380
381        let header = JsonlV4Header {
382            kind: HeaderKind,
383            version: 4,
384            id: "s-export".to_string(),
385            created_at: 0,
386            cwd: "/tmp".to_string(),
387            parent_session_id: None,
388            legacy_parent_session_path: None,
389            metadata: None,
390        };
391
392        let mut lines = vec![encode_header(&header)];
393        for (seq, message) in [
394            (
395                1u64,
396                rpi_agent::message::AgentMessage::User(rpi_ai::types::UserMessage::new(
397                    rpi_ai::types::UserContent::Text("hello <world>".to_string()),
398                    0,
399                )),
400            ),
401            (
402                2u64,
403                rpi_agent::message::AgentMessage::Assistant(Box::new({
404                    let mut a = rpi_ai::types::AssistantMessage::empty(
405                        rpi_ai::Api::AnthropicMessages,
406                        "anthropic",
407                        "m",
408                        0,
409                    );
410                    a.content = vec![rpi_ai::types::Content::Text(rpi_ai::types::TextContent {
411                        kind: rpi_ai::types::TextContentType,
412                        text: "hi there".to_string(),
413                        text_signature: None,
414                    })];
415                    a
416                })),
417            ),
418        ] {
419            let entry = Entry::Message(MessageEntry {
420                base: EntryBase {
421                    entry_type: "message".to_string(),
422                    id: format!("e{seq}"),
423                    seq,
424                    parent_id: None,
425                    timestamp: 0,
426                },
427                message,
428                terminate: None,
429            });
430            lines.push(encode_mutation(&SessionMutation::Entry {
431                seq,
432                timestamp: 0,
433                lane: Some("main".to_string()),
434                entry,
435            }));
436        }
437        std::fs::write(&session_path, lines.concat()).unwrap();
438
439        // Markdown
440        let md_path = dir.path().join("out.md");
441        export_file(&session_path, &md_path).unwrap();
442        let md = std::fs::read_to_string(&md_path).unwrap();
443        assert!(md.contains("hello <world>"), "{md}");
444        assert!(md.contains("hi there"), "{md}");
445
446        // HTML escapes the angle brackets in the user text.
447        let html_path = dir.path().join("out.html");
448        export_file(&session_path, &html_path).unwrap();
449        let html = std::fs::read_to_string(&html_path).unwrap();
450        assert!(html.contains("&lt;world&gt;"), "{html}");
451        assert!(html.contains("hi there"), "{html}");
452        assert!(html.trim_end().ends_with("</html>"), "{html}");
453
454        // JSONL re-emits the parsed entries (not the original wrapper lines).
455        let jsonl_path = dir.path().join("out.jsonl");
456        export_file(&session_path, &jsonl_path).unwrap();
457        let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
458        let parsed: Vec<Entry> = jsonl
459            .lines()
460            .filter(|l| !l.trim().is_empty())
461            .map(|l| serde_json::from_str(l).unwrap())
462            .collect();
463        assert_eq!(parsed.len(), 2);
464    }
465
466    /// The write-failure warning tells the user to export before exiting, and
467    /// JSONL storage applies an entry only after the append succeeds — so a
468    /// refused message exists nowhere the session-backed export can read. This
469    /// drives the production export with a recovery copy present and asserts the
470    /// file actually contains the refused text, in every format.
471    #[test]
472    fn a_refused_write_reaches_the_export_through_the_recovery_copy() {
473        let dir = tempfile::tempdir().unwrap();
474        let message = rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage {
475            role: rpi_ai::types::UserRole,
476            content: rpi_ai::types::UserContent::Text("only on screen".into()),
477            timestamp: 0,
478        });
479        let unsaved = vec![rpi_harness::agent_harness::UnsavedMessage {
480            message,
481            what: "a message you sent".to_string(),
482            at: 0,
483            session_id: "sess".to_string(),
484            lane: "main".to_string(),
485        }];
486
487        let md_path = dir.path().join("out.md");
488        export_markdown(&[], &unsaved, &md_path).unwrap();
489        let md = std::fs::read_to_string(&md_path).unwrap();
490        assert!(md.contains("only on screen"), "{md}");
491        assert!(
492            md.contains("Not saved"),
493            "the export must say the message is not in the session log: {md}"
494        );
495
496        let html_path = dir.path().join("out.html");
497        export_html(&[], &unsaved, &html_path).unwrap();
498        let html = std::fs::read_to_string(&html_path).unwrap();
499        assert!(html.contains("only on screen"), "{html}");
500        assert!(html.trim_end().ends_with("</html>"), "{html}");
501
502        let jsonl_path = dir.path().join("out.jsonl");
503        export_jsonl(&[], &unsaved, &jsonl_path).unwrap();
504        let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
505        let line: serde_json::Value = serde_json::from_str(jsonl.lines().next().unwrap()).unwrap();
506        assert_eq!(line["type"], "unsaved_message", "{jsonl}");
507        assert_eq!(
508            line["message"]["content"], "only on screen",
509            "the machine-readable export must carry the message body: {jsonl}"
510        );
511    }
512
513    #[test]
514    fn export_file_rejects_a_non_session_file() {
515        let dir = tempfile::tempdir().unwrap();
516        let bogus = dir.path().join("not-a-session.jsonl");
517        std::fs::write(&bogus, "{\"type\":\"message\"}\n").unwrap();
518        let out = dir.path().join("out.md");
519        let err = export_file(&bogus, &out).unwrap_err();
520        assert!(err.contains("not a JSONL v4 session file"), "{err}");
521    }
522
523    /// The maintainer's isolation probe, verbatim in shape: preload a recovery
524    /// copy for session A through the test hook, switch to an empty ephemeral
525    /// session B, export B through the production `export_session` — the file
526    /// must not contain A's refused content. Combined with the harness-side
527    /// `review_session_switch_does_not_expose_previous_session_recovery`, this
528    /// pins both ends of the isolation contract (scoped reader + real switch).
529    #[tokio::test]
530    async fn review_session_switch_does_not_export_previous_session_recovery() {
531        let harness = AgentHarness::create(Default::default()).await.unwrap();
532        harness.keep_unsaved_message_for_test(
533            "old session message",
534            rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new("SESSION_A_SECRET", 1)),
535        );
536        harness
537            .set_session(crate::session::ephemeral_session())
538            .await
539            .unwrap();
540        let dir = tempfile::tempdir().unwrap();
541        let output = dir.path().join("session-b.md");
542        export_session(&harness, ExportFormat::Markdown, &output)
543            .await
544            .unwrap();
545        let text = std::fs::read_to_string(output).unwrap();
546        assert!(
547            !text.contains("SESSION_A_SECRET"),
548            "session B export leaked session A's recovery data: {text}"
549        );
550    }
551}