1use std::path::Path;
6
7use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
8use rpi_harness::agent_harness::AgentHarness;
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)
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('&', "&")
241 .replace('<', "<")
242 .replace('>', ">")
243 .replace('"', """)
244 .replace('\'', "'")
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 "<script>alert('xss')</script>"
259 );
260 assert_eq!(escape_html("Hello & World"), "Hello & World");
261 }
262
263 #[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 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 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("<world>"), "{html}");
340 assert!(html.contains("hi there"), "{html}");
341 assert!(html.trim_end().ends_with("</html>"), "{html}");
342
343 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}