1use std::path::Path;
6
7use rpi_harness::agent_harness::AgentHarness;
8use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum ExportFormat {
13 Markdown,
14 Html,
15 Jsonl,
16}
17
18pub fn export_file(input: &Path, output: &Path) -> Result<(), String> {
25 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
49fn 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 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
94pub 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('&', "&")
243 .replace('<', "<")
244 .replace('>', ">")
245 .replace('"', """)
246 .replace('\'', "'")
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 "<script>alert('xss')</script>"
261 );
262 assert_eq!(escape_html("Hello & World"), "Hello & World");
263 }
264
265 #[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 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 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("<world>"), "{html}");
342 assert!(html.contains("hi there"), "{html}");
343 assert!(html.trim_end().ends_with("</html>"), "{html}");
344
345 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}