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