1use crate::conversation::message::MessageContent;
6use crate::session::{Session, SessionManager};
7use anyhow::Result;
8
9#[derive(Debug, Clone, Copy, Default)]
11pub enum ExportFormat {
12 #[default]
13 Json,
14 Markdown,
15 Html,
16}
17
18#[derive(Debug, Clone, Default)]
20pub struct ExportOptions {
21 pub format: ExportFormat,
23 pub include_messages: bool,
25 pub include_metadata: bool,
27 pub pretty_print: bool,
29}
30
31impl ExportOptions {
32 pub fn new() -> Self {
33 Self {
34 format: ExportFormat::Json,
35 include_messages: true,
36 include_metadata: true,
37 pretty_print: true,
38 }
39 }
40
41 pub fn format(mut self, format: ExportFormat) -> Self {
42 self.format = format;
43 self
44 }
45
46 pub fn include_messages(mut self, include: bool) -> Self {
47 self.include_messages = include;
48 self
49 }
50
51 pub fn include_metadata(mut self, include: bool) -> Self {
52 self.include_metadata = include;
53 self
54 }
55}
56
57pub async fn export_session(session_id: &str, options: ExportOptions) -> Result<String> {
59 let session = SessionManager::get_session(session_id, options.include_messages).await?;
60
61 match options.format {
62 ExportFormat::Json => export_to_json(&session, &options),
63 ExportFormat::Markdown => export_to_markdown(&session, &options),
64 ExportFormat::Html => export_to_html(&session, &options),
65 }
66}
67
68fn export_to_json(session: &Session, options: &ExportOptions) -> Result<String> {
70 if options.pretty_print {
71 serde_json::to_string_pretty(session).map_err(Into::into)
72 } else {
73 serde_json::to_string(session).map_err(Into::into)
74 }
75}
76
77fn export_to_markdown(session: &Session, options: &ExportOptions) -> Result<String> {
79 let mut lines = Vec::new();
80
81 lines.push(format!("# {}", session.name));
83 lines.push(String::new());
84
85 if options.include_metadata {
87 lines.push("## Metadata".to_string());
88 lines.push(String::new());
89 lines.push(format!("- **ID:** {}", session.id));
90 lines.push(format!("- **Created:** {}", session.created_at));
91 lines.push(format!("- **Updated:** {}", session.updated_at));
92 lines.push(format!(
93 "- **Working Directory:** {}",
94 session.working_dir.display()
95 ));
96 lines.push(format!("- **Messages:** {}", session.message_count));
97
98 if let Some(tokens) = session.total_tokens {
99 lines.push(format!("- **Total Tokens:** {}", tokens));
100 }
101 if let Some(input) = session.input_tokens {
102 lines.push(format!("- **Input Tokens:** {}", input));
103 }
104 if let Some(output) = session.output_tokens {
105 lines.push(format!("- **Output Tokens:** {}", output));
106 }
107
108 lines.push(String::new());
109 lines.push("---".to_string());
110 lines.push(String::new());
111 }
112
113 if options.include_messages {
115 if let Some(conversation) = &session.conversation {
116 lines.push("## Conversation".to_string());
117 lines.push(String::new());
118
119 for (i, message) in conversation.messages().iter().enumerate() {
120 let role = match message.role {
121 rmcp::model::Role::User => "User",
122 rmcp::model::Role::Assistant => "Assistant",
123 };
124
125 lines.push(format!("### Message {}: {}", i + 1, role));
126 lines.push(String::new());
127
128 for content in &message.content {
129 match content {
130 MessageContent::Text(tc) => {
131 lines.push(tc.text.clone());
132 }
133 MessageContent::ToolRequest(tr) => {
134 lines.push(format!("**Tool:** {}", tr.to_readable_string()));
135 lines.push("```json".to_string());
136 if let Ok(json) = serde_json::to_string_pretty(&tr) {
137 lines.push(json);
138 }
139 lines.push("```".to_string());
140 }
141 MessageContent::ToolResponse(resp) => {
142 lines.push("**Tool Result:**".to_string());
143 lines.push("```".to_string());
144 match &resp.tool_result {
145 Ok(result) => {
146 for item in &result.content {
147 if let Some(text) = item.as_text() {
148 lines.push(text.text.clone());
149 } else {
150 lines.push(format!("{:?}", item));
151 }
152 }
153 }
154 Err(e) => {
155 lines.push(format!("Error: {:?}", e));
156 }
157 }
158 lines.push("```".to_string());
159 }
160 MessageContent::Thinking(t) => {
161 lines.push(format!("*Thinking: {}*", t.thinking));
162 }
163 _ => {}
164 }
165 }
166
167 lines.push(String::new());
168 lines.push("---".to_string());
169 lines.push(String::new());
170 }
171 }
172 }
173
174 Ok(lines.join("\n"))
175}
176
177fn export_to_html(session: &Session, options: &ExportOptions) -> Result<String> {
179 let mut html = String::new();
180
181 html.push_str("<!DOCTYPE html>\n");
183 html.push_str("<html lang=\"en\">\n");
184 html.push_str("<head>\n");
185 html.push_str(" <meta charset=\"UTF-8\">\n");
186 html.push_str(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
187 html.push_str(&format!(
188 " <title>{}</title>\n",
189 escape_html(&session.name)
190 ));
191 html.push_str(" <style>\n");
192 html.push_str(HTML_STYLES);
193 html.push_str(" </style>\n");
194 html.push_str("</head>\n");
195 html.push_str("<body>\n");
196
197 html.push_str(&format!(" <h1>{}</h1>\n", escape_html(&session.name)));
199
200 if options.include_metadata {
202 html.push_str(" <div class=\"metadata\">\n");
203 html.push_str(" <h2>Session Information</h2>\n");
204 html.push_str(" <ul>\n");
205 html.push_str(&format!(
206 " <li><strong>ID:</strong> {}</li>\n",
207 escape_html(&session.id)
208 ));
209 html.push_str(&format!(
210 " <li><strong>Created:</strong> {}</li>\n",
211 session.created_at
212 ));
213 html.push_str(&format!(
214 " <li><strong>Updated:</strong> {}</li>\n",
215 session.updated_at
216 ));
217 html.push_str(&format!(
218 " <li><strong>Working Directory:</strong> <code>{}</code></li>\n",
219 escape_html(&session.working_dir.to_string_lossy())
220 ));
221 html.push_str(&format!(
222 " <li><strong>Messages:</strong> {}</li>\n",
223 session.message_count
224 ));
225
226 if let Some(tokens) = session.total_tokens {
227 html.push_str(&format!(
228 " <li><strong>Total Tokens:</strong> {}</li>\n",
229 tokens
230 ));
231 }
232
233 html.push_str(" </ul>\n");
234 html.push_str(" </div>\n");
235 }
236
237 if options.include_messages {
239 if let Some(conversation) = &session.conversation {
240 html.push_str(" <h2>Conversation</h2>\n");
241
242 for (i, message) in conversation.messages().iter().enumerate() {
243 let (role, class) = match message.role {
244 rmcp::model::Role::User => ("User", "user-message"),
245 rmcp::model::Role::Assistant => ("Assistant", "assistant-message"),
246 };
247
248 html.push_str(&format!(" <div class=\"message {}\">\n", class));
249 html.push_str(&format!(" <h3>Message {}: {}</h3>\n", i + 1, role));
250
251 for content in &message.content {
252 match content {
253 MessageContent::Text(tc) => {
254 html.push_str(&format!(
255 " <p>{}</p>\n",
256 escape_html(&tc.text).replace('\n', "<br>")
257 ));
258 }
259 MessageContent::ToolRequest(tr) => {
260 html.push_str(" <div class=\"tool-use\">\n");
261 html.push_str(&format!(
262 " <strong>Tool:</strong> {}\n",
263 escape_html(&tr.to_readable_string())
264 ));
265 if let Ok(json) = serde_json::to_string_pretty(&tr) {
266 html.push_str(&format!(
267 " <pre><code>{}</code></pre>\n",
268 escape_html(&json)
269 ));
270 }
271 html.push_str(" </div>\n");
272 }
273 MessageContent::ToolResponse(resp) => {
274 html.push_str(" <div class=\"tool-result\">\n");
275 html.push_str(" <strong>Tool Result:</strong>\n");
276 html.push_str(" <pre><code>");
277 match &resp.tool_result {
278 Ok(result) => {
279 for item in &result.content {
280 if let Some(text) = item.as_text() {
281 html.push_str(&escape_html(&text.text));
282 } else {
283 html.push_str(&escape_html(&format!("{:?}", item)));
284 }
285 }
286 }
287 Err(e) => {
288 html.push_str(&escape_html(&format!("Error: {:?}", e)));
289 }
290 }
291 html.push_str("</code></pre>\n");
292 html.push_str(" </div>\n");
293 }
294 _ => {}
295 }
296 }
297
298 html.push_str(" </div>\n");
299 }
300 }
301 }
302
303 html.push_str("</body>\n");
305 html.push_str("</html>\n");
306
307 Ok(html)
308}
309
310fn escape_html(text: &str) -> String {
312 text.replace('&', "&")
313 .replace('<', "<")
314 .replace('>', ">")
315 .replace('"', """)
316 .replace('\'', "'")
317}
318
319const HTML_STYLES: &str = r#"
321 body {
322 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
323 max-width: 900px;
324 margin: 40px auto;
325 padding: 20px;
326 line-height: 1.6;
327 color: #333;
328 }
329 h1 { border-bottom: 2px solid #007acc; padding-bottom: 10px; }
330 h2 { color: #007acc; margin-top: 30px; }
331 h3 { color: #555; }
332 .metadata {
333 background: #f5f5f5;
334 padding: 15px;
335 border-radius: 5px;
336 margin-bottom: 20px;
337 }
338 .metadata ul { list-style: none; padding: 0; }
339 .metadata li { padding: 5px 0; }
340 .metadata strong { color: #007acc; }
341 .message {
342 margin: 20px 0;
343 padding: 15px;
344 border-radius: 5px;
345 }
346 .user-message {
347 background: #e3f2fd;
348 border-left: 4px solid #2196f3;
349 }
350 .assistant-message {
351 background: #f3e5f5;
352 border-left: 4px solid #9c27b0;
353 }
354 .tool-use {
355 background: #fff3e0;
356 padding: 10px;
357 border-radius: 3px;
358 margin: 10px 0;
359 }
360 .tool-result {
361 background: #e8f5e9;
362 padding: 10px;
363 border-radius: 3px;
364 margin: 10px 0;
365 }
366 pre {
367 background: #f5f5f5;
368 padding: 10px;
369 border-radius: 3px;
370 overflow-x: auto;
371 }
372 code { font-family: "Courier New", monospace; }
373"#;
374
375pub async fn bulk_export_sessions(
377 session_ids: &[String],
378 format: ExportFormat,
379) -> std::collections::HashMap<String, Result<String>> {
380 let mut results = std::collections::HashMap::new();
381
382 for id in session_ids {
383 let options = ExportOptions::new().format(format);
384 let result = export_session(id, options).await;
385 results.insert(id.clone(), result);
386 }
387
388 results
389}
390
391pub async fn export_session_to_file(
393 session_id: &str,
394 file_path: &std::path::Path,
395 format: ExportFormat,
396) -> Result<()> {
397 let options = ExportOptions::new().format(format);
398 let content = export_session(session_id, options).await?;
399 std::fs::write(file_path, content)?;
400 Ok(())
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_escape_html() {
409 assert_eq!(escape_html("<script>"), "<script>");
410 assert_eq!(escape_html("a & b"), "a & b");
411 assert_eq!(escape_html("\"quoted\""), ""quoted"");
412 }
413
414 #[test]
415 fn test_export_options_builder() {
416 let options = ExportOptions::new()
417 .format(ExportFormat::Markdown)
418 .include_messages(false)
419 .include_metadata(true);
420
421 assert!(matches!(options.format, ExportFormat::Markdown));
422 assert!(!options.include_messages);
423 assert!(options.include_metadata);
424 }
425}