Skip to main content

aster/session/
export.rs

1//! Session Export Support
2//!
3//! Provides multi-format export functionality for sessions.
4
5use crate::conversation::message::MessageContent;
6use crate::session::{Session, SessionManager};
7use anyhow::Result;
8
9/// Export format options
10#[derive(Debug, Clone, Copy, Default)]
11pub enum ExportFormat {
12    #[default]
13    Json,
14    Markdown,
15    Html,
16}
17
18/// Export options
19#[derive(Debug, Clone, Default)]
20pub struct ExportOptions {
21    /// Export format
22    pub format: ExportFormat,
23    /// Include messages in export
24    pub include_messages: bool,
25    /// Include metadata in export
26    pub include_metadata: bool,
27    /// Pretty print JSON output
28    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
57/// Export a session to the specified format
58pub 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
68/// Export session to JSON format
69fn 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
77/// Export session to Markdown format
78fn export_to_markdown(session: &Session, options: &ExportOptions) -> Result<String> {
79    let mut lines = Vec::new();
80
81    // Title
82    lines.push(format!("# {}", session.name));
83    lines.push(String::new());
84
85    // Metadata
86    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    // Messages
114    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
177/// Export session to HTML format
178fn export_to_html(session: &Session, options: &ExportOptions) -> Result<String> {
179    let mut html = String::new();
180
181    // HTML header
182    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    // Title
198    html.push_str(&format!("  <h1>{}</h1>\n", escape_html(&session.name)));
199
200    // Metadata
201    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    // Messages
238    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 footer
304    html.push_str("</body>\n");
305    html.push_str("</html>\n");
306
307    Ok(html)
308}
309
310/// HTML escape helper
311fn escape_html(text: &str) -> String {
312    text.replace('&', "&amp;")
313        .replace('<', "&lt;")
314        .replace('>', "&gt;")
315        .replace('"', "&quot;")
316        .replace('\'', "&#039;")
317}
318
319/// HTML styles for export
320const 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
375/// Bulk export multiple sessions
376pub 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
391/// Export session to file
392pub 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>"), "&lt;script&gt;");
410        assert_eq!(escape_html("a & b"), "a &amp; b");
411        assert_eq!(escape_html("\"quoted\""), "&quot;quoted&quot;");
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}