Skip to main content

oxi/
export.rs

1//! Export conversation sessions to standalone HTML files.
2//!
3//! Produces a self-contained HTML document with embedded CSS and JS that renders
4//! a conversation session with:
5//! - Color-coded user / assistant / system messages
6//! - Markdown → HTML rendering (basic, no external deps)
7//! - Tool calls and results in styled blocks
8//! - Collapsible thinking blocks
9//! - Metadata header (model, provider, date, token counts)
10//! - Dark theme (default) with light-theme toggle
11//! - Syntax highlighting for code blocks (JS-only, via highlight.js CDN)
12//! - Session tree navigation for branched sessions
13
14use anyhow::Result;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::fmt::Write as FmtWrite;
18use uuid::Uuid;
19
20use crate::session::{AgentMessage, SessionEntry, SessionMeta};
21
22// ── Public types ─────────────────────────────────────────────────────
23
24/// Metadata attached to an export (optional but encouraged).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExportMeta {
27    pub model: Option<String>,
28    pub provider: Option<String>,
29    pub exported_at: i64,
30    pub total_user_tokens: Option<u64>,
31    pub total_assistant_tokens: Option<u64>,
32}
33
34impl Default for ExportMeta {
35    fn default() -> Self {
36        Self {
37            model: None,
38            provider: None,
39            exported_at: Utc::now().timestamp_millis(),
40            total_user_tokens: None,
41            total_assistant_tokens: None,
42        }
43    }
44}
45
46/// A single rendered node in the session tree.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TreeNode {
49    pub session_id: Uuid,
50    pub name: Option<String>,
51    pub is_current: bool,
52    pub children: Vec<TreeNode>,
53}
54
55// ── Core export function ─────────────────────────────────────────────
56
57/// Render a flat list of session entries into a self-contained HTML string.
58///
59/// `tree` is optional – when provided, a sidebar with session-tree navigation
60/// is included.
61pub fn export_html(
62    entries: &[SessionEntry],
63    meta: &ExportMeta,
64    session_meta: Option<&SessionMeta>,
65    tree: Option<&TreeNode>,
66) -> Result<String> {
67    let mut html = String::with_capacity(64 * 1024);
68
69    // ── Head ──────────────────────────────────────────────────────
70    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
71    html.push_str("<meta charset=\"utf-8\">\n");
72    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
73
74    let title = session_meta
75        .and_then(|m| m.name.clone())
76        .unwrap_or_else(|| "oxi session export".to_string());
77    write!(html, "<title>{}</title>\n", html_escape(&title))?;
78
79    // highlight.js CDN for syntax highlighting
80    html.push_str(
81        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\" id=\"hljs-dark\">\n",
82    );
83    html.push_str(
84        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\" id=\"hljs-light\" disabled>\n",
85    );
86    html.push_str(
87        "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n",
88    );
89
90    // Embedded CSS
91    html.push_str("<style>\n");
92    html.push_str(CSS);
93    html.push_str("\n</style>\n");
94    html.push_str("</head>\n");
95
96    // ── Body ──────────────────────────────────────────────────────
97    html.push_str("<body class=\"dark\">\n");
98
99    // Theme toggle button
100    html.push_str("<button id=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"Toggle light/dark theme\">");
101    html.push_str("🌓</button>\n");
102
103    // Optional tree sidebar
104    if let Some(node) = tree {
105        html.push_str("<nav class=\"tree-nav\">\n<h3>Session Tree</h3>\n");
106        render_tree_node(&mut html, node, 0)?;
107        html.push_str("</nav>\n");
108    }
109
110    // Main content
111    html.push_str("<main class=\"content\">\n");
112
113    // Metadata header
114    render_meta_header(&mut html, meta, session_meta)?;
115
116    // Messages
117    for entry in entries {
118        render_entry(&mut html, entry)?;
119    }
120
121    html.push_str("</main>\n");
122
123    // Embedded JS
124    html.push_str("<script>\n");
125    html.push_str(JS);
126    html.push_str("\n</script>\n");
127
128    html.push_str("</body>\n</html>\n");
129    Ok(html)
130}
131
132// ── Internal rendering helpers ───────────────────────────────────────
133
134fn render_meta_header(
135    html: &mut String,
136    meta: &ExportMeta,
137    session_meta: Option<&SessionMeta>,
138) -> Result<()> {
139    html.push_str("<header class=\"meta-header\">\n");
140    html.push_str("<h1>oxi Session Export</h1>\n");
141    html.push_str("<table class=\"meta-table\">\n");
142
143    let exported_dt = DateTime::from_timestamp_millis(meta.exported_at)
144        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
145        .unwrap_or_else(|| "unknown".to_string());
146    render_meta_row(html, "Exported", &exported_dt)?;
147
148    if let Some(model) = &meta.model {
149        render_meta_row(html, "Model", model)?;
150    }
151    if let Some(provider) = &meta.provider {
152        render_meta_row(html, "Provider", provider)?;
153    }
154    if let Some(sm) = session_meta {
155        let created_dt = DateTime::from_timestamp_millis(sm.created_at)
156            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
157            .unwrap_or_else(|| "unknown".to_string());
158        render_meta_row(html, "Session ID", &sm.id.to_string())?;
159        render_meta_row(html, "Created", &created_dt)?;
160        if let Some(name) = &sm.name {
161            render_meta_row(html, "Name", name)?;
162        }
163    }
164    if let Some(t) = meta.total_user_tokens {
165        render_meta_row(html, "User Tokens", &t.to_string())?;
166    }
167    if let Some(t) = meta.total_assistant_tokens {
168        render_meta_row(html, "Assistant Tokens", &t.to_string())?;
169    }
170
171    html.push_str("</table>\n</header>\n");
172    Ok(())
173}
174
175fn render_meta_row(html: &mut String, label: &str, value: &str) -> Result<()> {
176    write!(
177        html,
178        "<tr><td class=\"meta-label\">{}</td><td class=\"meta-value\">{}</td></tr>\n",
179        html_escape(label),
180        html_escape(value)
181    )?;
182    Ok(())
183}
184
185fn render_entry(html: &mut String, entry: &SessionEntry) -> Result<()> {
186    let ts = DateTime::from_timestamp_millis(entry.timestamp)
187        .map(|dt| dt.format("%H:%M:%S").to_string())
188        .unwrap_or_else(|| "".to_string());
189
190    match &entry.message {
191        AgentMessage::User { content } => {
192            html.push_str("<div class=\"msg msg-user\">\n");
193            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">You</span>");
194            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
195            html.push_str("</div>\n");
196            html.push_str("<div class=\"msg-body\">");
197            html.push_str(&render_markdown(content));
198            html.push_str("</div>\n</div>\n");
199        }
200        AgentMessage::Assistant { content } => {
201            html.push_str("<div class=\"msg msg-assistant\">\n");
202            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">Assistant</span>");
203            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
204            html.push_str("</div>\n");
205            html.push_str("<div class=\"msg-body\">");
206            html.push_str(&render_markdown(content));
207            html.push_str("</div>\n</div>\n");
208        }
209        AgentMessage::System { content } => {
210            html.push_str("<div class=\"msg msg-system\">\n");
211            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
212            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
213            html.push_str("</div>\n");
214            html.push_str("<div class=\"msg-body\">");
215            html.push_str(&render_markdown(content));
216            html.push_str("</div>\n</div>\n");
217        }
218    }
219    Ok(())
220}
221
222/// Recursively render a session-tree sidebar node.
223fn render_tree_node(html: &mut String, node: &TreeNode, depth: usize) -> Result<()> {
224    let indent = "&nbsp;".repeat(depth * 4);
225    let current = if node.is_current { " tree-current" } else { "" };
226    let fallback = node.session_id.to_string();
227    let short_id = &fallback[..8.min(fallback.len())];
228    let name = node
229        .name
230        .as_deref()
231        .unwrap_or(short_id);
232    write!(
233        html,
234        "<div class=\"tree-node{}\">{}<a href=\"#\">{}</a></div>\n",
235        current,
236        indent,
237        html_escape(name)
238    )?;
239    for child in &node.children {
240        render_tree_node(html, child, depth + 1)?;
241    }
242    Ok(())
243}
244
245// ── Minimal markdown → HTML renderer ─────────────────────────────────
246//
247// Handles: code blocks (```), inline code (`), bold (**), italic (*),
248// headers (#), unordered lists (- ), links, and paragraphs.
249// This is intentionally lightweight to avoid pulling in a heavy crate.
250
251fn render_markdown(input: &str) -> String {
252    let mut out = String::with_capacity(input.len() * 2);
253    let mut in_code_block = false;
254    let mut code_lang = String::new();
255    let mut code_buf = String::new();
256    let mut in_thinking = false;
257    let mut think_buf = String::new();
258    let mut lines = input.lines().peekable();
259
260    while let Some(line) = lines.next() {
261        // ── Fenced code blocks ────────────────────────────────────
262        if line.starts_with("```") {
263            if in_code_block {
264                // close
265                out.push_str("<pre><code class=\"language-");
266                out.push_str(&html_escape(&code_lang));
267                out.push_str("\">");
268                out.push_str(&html_escape(&code_buf));
269                out.push_str("</code></pre>\n");
270                code_buf.clear();
271                code_lang.clear();
272                in_code_block = false;
273            } else {
274                in_code_block = true;
275                code_lang = line.trim_start_matches('`').trim().to_string();
276            }
277            continue;
278        }
279        if in_code_block {
280            code_buf.push_str(line);
281            code_buf.push('\n');
282            continue;
283        }
284
285        // ── Thinking blocks ───────────────────────────────────────
286        if line.trim() == "<think/>" {
287            // skip empty self-closing
288            continue;
289        }
290        if line.trim() == "<think" || line.trim() == "<thinking>" {
291            in_thinking = true;
292            continue;
293        }
294        if in_thinking && (line.trim() == "</think" || line.trim() == "</thinking>") {
295            // emit collapsible
296            out.push_str("<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">");
297            out.push_str(&render_inline(&think_buf));
298            out.push_str("</div></details>\n");
299            think_buf.clear();
300            in_thinking = false;
301            continue;
302        }
303        if in_thinking {
304            think_buf.push_str(line);
305            think_buf.push('\n');
306            continue;
307        }
308
309        // ── Tool calls ────────────────────────────────────────────
310        if line.starts_with("🔧 ") || line.starts_with("tool:") {
311            out.push_str("<div class=\"tool-call\">");
312            out.push_str(&render_inline(line));
313            out.push_str("</div>\n");
314            continue;
315        }
316        if line.starts_with("📤 ") || line.starts_with("result:") {
317            out.push_str("<div class=\"tool-result\">");
318            out.push_str(&render_inline(line));
319            out.push_str("</div>\n");
320            continue;
321        }
322
323        // ── Headings ──────────────────────────────────────────────
324        if line.starts_with("### ") {
325            out.push_str("<h3>");
326            out.push_str(&render_inline(&line[4..]));
327            out.push_str("</h3>\n");
328            continue;
329        }
330        if line.starts_with("## ") {
331            out.push_str("<h2>");
332            out.push_str(&render_inline(&line[3..]));
333            out.push_str("</h2>\n");
334            continue;
335        }
336        if line.starts_with("# ") {
337            out.push_str("<h1>");
338            out.push_str(&render_inline(&line[2..]));
339            out.push_str("</h1>\n");
340            continue;
341        }
342
343        // ── Unordered list items ──────────────────────────────────
344        if line.starts_with("- ") || line.starts_with("* ") {
345            out.push_str("<li>");
346            out.push_str(&render_inline(&line[2..]));
347            out.push_str("</li>\n");
348            continue;
349        }
350
351        // ── Empty line = paragraph break ──────────────────────────
352        if line.trim().is_empty() {
353            out.push_str("<br>\n");
354            continue;
355        }
356
357        // ── Regular paragraph ─────────────────────────────────────
358        out.push_str("<p>");
359        out.push_str(&render_inline(line));
360        out.push_str("</p>\n");
361    }
362
363    // Close any still-open code block
364    if in_code_block {
365        out.push_str("<pre><code>");
366        out.push_str(&html_escape(&code_buf));
367        out.push_str("</code></pre>\n");
368    }
369
370    out
371}
372
373/// Inline formatting: bold, italic, inline code, links.
374fn render_inline(input: &str) -> String {
375    let mut out = String::with_capacity(input.len() * 2);
376    let mut chars = input.char_indices().peekable();
377    let bytes = input.as_bytes();
378
379    while let Some((i, ch)) = chars.next() {
380        match ch {
381            '`' => {
382                // inline code
383                let start = i + 1;
384                let end = bytes[start..]
385                    .iter()
386                    .position(|&b| b == b'`')
387                    .map(|pos| start + pos)
388                    .unwrap_or(input.len());
389                let code = &input[start..end];
390                out.push_str("<code>");
391                out.push_str(&html_escape(code));
392                out.push_str("</code>");
393                // skip past closing backtick
394                if end < input.len() {
395                    for _ in input[i..=end].chars() {
396                        chars.next();
397                    }
398                }
399            }
400            '*' => {
401                // Look ahead for bold (**)
402                if bytes.get(i + 1) == Some(&b'*') {
403                    let rest = &input[i + 2..];
404                    if let Some(end_pos) = rest.find("**") {
405                        out.push_str("<strong>");
406                        out.push_str(&render_inline(&rest[..end_pos]));
407                        out.push_str("</strong>");
408                        // skip past closing **
409                        for _ in input[i..=i + 2 + end_pos + 1].chars() {
410                            chars.next();
411                        }
412                        continue;
413                    }
414                }
415                // Italic (*)
416                let rest = &input[i + 1..];
417                if let Some(end_pos) = rest.find('*') {
418                    out.push_str("<em>");
419                    out.push_str(&render_inline(&rest[..end_pos]));
420                    out.push_str("</em>");
421                    for _ in input[i..=i + 1 + end_pos].chars() {
422                        chars.next();
423                    }
424                    continue;
425                }
426                out.push('*');
427            }
428            '[' => {
429                // Markdown link [text](url)
430                let rest = &input[i..];
431                if let Some(link_end) = rest.find(')') {
432                    if let Some(mid) = rest.find("](") {
433                        let text = &rest[1..mid];
434                        let url = &rest[mid + 2..link_end];
435                        out.push_str("<a href=\"");
436                        out.push_str(&html_escape(url));
437                        out.push_str("\">");
438                        out.push_str(&html_escape(text));
439                        out.push_str("</a>");
440                        // skip entire link
441                        for _ in rest[..=link_end].chars() {
442                            chars.next();
443                        }
444                        continue;
445                    }
446                }
447                out.push('[');
448            }
449            '<' => {
450                // escape HTML
451                out.push_str("&lt;");
452            }
453            '>' => {
454                out.push_str("&gt;");
455            }
456            '&' => {
457                out.push_str("&amp;");
458            }
459            _ => {
460                out.push(ch);
461            }
462        }
463    }
464    out
465}
466
467fn html_escape(input: &str) -> String {
468    let mut s = String::with_capacity(input.len());
469    for ch in input.chars() {
470        match ch {
471            '<' => s.push_str("&lt;"),
472            '>' => s.push_str("&gt;"),
473            '&' => s.push_str("&amp;"),
474            '"' => s.push_str("&quot;"),
475            '\'' => s.push_str("&#39;"),
476            _ => s.push(ch),
477        }
478    }
479    s
480}
481
482// ── Embedded CSS ─────────────────────────────────────────────────────
483
484const CSS: &str = r#"
485/* ── Reset & base ──────────────────────────────────────────────── */
486*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
487
488body {
489  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
490  line-height: 1.6;
491  padding: 1rem;
492  display: flex;
493  min-height: 100vh;
494}
495
496/* ── Dark theme (default) ─────────────────────────────────────── */
497body.dark {
498  background: #1a1b26;
499  color: #c0caf5;
500}
501
502/* ── Light theme ──────────────────────────────────────────────── */
503body.light {
504  background: #f8f9fc;
505  color: #1a1b26;
506}
507
508/* ── Theme toggle button ──────────────────────────────────────── */
509#theme-toggle {
510  position: fixed;
511  top: 1rem;
512  right: 1rem;
513  z-index: 100;
514  background: rgba(255,255,255,0.1);
515  border: 1px solid rgba(255,255,255,0.2);
516  border-radius: 8px;
517  padding: 0.4rem 0.7rem;
518  cursor: pointer;
519  font-size: 1.2rem;
520}
521body.light #theme-toggle {
522  background: rgba(0,0,0,0.05);
523  border-color: rgba(0,0,0,0.15);
524}
525
526/* ── Tree sidebar ──────────────────────────────────────────────── */
527.tree-nav {
528  width: 220px;
529  min-width: 220px;
530  padding: 1rem;
531  margin-right: 1rem;
532  border-right: 1px solid rgba(255,255,255,0.1);
533  font-size: 0.85rem;
534  overflow-y: auto;
535}
536body.light .tree-nav { border-color: rgba(0,0,0,0.12); }
537.tree-nav h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
538.tree-node { padding: 0.2rem 0; }
539.tree-node a { text-decoration: none; color: inherit; opacity: 0.7; }
540.tree-node a:hover { opacity: 1; }
541.tree-current a { font-weight: bold; opacity: 1; }
542body.dark .tree-current a { color: #7aa2f7; }
543body.light .tree-current a { color: #1d4ed8; }
544
545/* ── Main content ──────────────────────────────────────────────── */
546.content {
547  flex: 1;
548  max-width: 900px;
549  margin: 0 auto;
550}
551
552/* ── Metadata header ───────────────────────────────────────────── */
553.meta-header { margin-bottom: 1.5rem; }
554.meta-header h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
555.meta-table { border-collapse: collapse; font-size: 0.9rem; }
556.meta-table td { padding: 0.15rem 0.75rem 0.15rem 0; }
557.meta-label { color: #7982a9; font-weight: 600; }
558body.light .meta-label { color: #6b7280; }
559
560/* ── Message bubbles ───────────────────────────────────────────── */
561.msg {
562  border-radius: 10px;
563  padding: 0.75rem 1rem;
564  margin-bottom: 0.75rem;
565  max-width: 100%;
566  word-wrap: break-word;
567  overflow-wrap: break-word;
568}
569
570.msg-header {
571  display: flex;
572  justify-content: space-between;
573  align-items: center;
574  margin-bottom: 0.35rem;
575  font-size: 0.82rem;
576}
577
578.msg-role { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
579.msg-time { opacity: 0.5; font-size: 0.78rem; }
580
581.msg-body p { margin: 0.25rem 0; }
582.msg-body h1, .msg-body h2, .msg-body h3 { margin: 0.6rem 0 0.25rem; }
583.msg-body li { margin-left: 1.2rem; }
584
585/* ── User message ──────────────────────────────────────────────── */
586body.dark .msg-user  { background: #24283b; border-left: 4px solid #7aa2f7; }
587body.light .msg-user { background: #eef2ff; border-left: 4px solid #6366f1; }
588.msg-user .msg-role { color: #7aa2f7; }
589body.light .msg-user .msg-role { color: #4f46e5; }
590
591/* ── Assistant message ─────────────────────────────────────────── */
592body.dark .msg-assistant  { background: #1f2335; border-left: 4px solid #9ece6a; }
593body.light .msg-assistant { background: #f0fdf4; border-left: 4px solid #22c55e; }
594.msg-assistant .msg-role { color: #9ece6a; }
595body.light .msg-assistant .msg-role { color: #16a34a; }
596
597/* ── System message ────────────────────────────────────────────── */
598body.dark .msg-system  { background: #292e42; border-left: 4px solid #ff9e64; }
599body.light .msg-system { background: #fffbeb; border-left: 4px solid #f59e0b; }
600.msg-system .msg-role { color: #ff9e64; }
601body.light .msg-system .msg-role { color: #d97706; }
602
603/* ── Code blocks ───────────────────────────────────────────────── */
604pre {
605  background: #13141c;
606  border-radius: 6px;
607  padding: 0.75rem 1rem;
608  overflow-x: auto;
609  margin: 0.5rem 0;
610  font-size: 0.88rem;
611}
612body.light pre { background: #f1f5f9; }
613pre code { font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; }
614
615code {
616  background: rgba(255,255,255,0.07);
617  padding: 0.1rem 0.3rem;
618  border-radius: 3px;
619  font-family: "JetBrains Mono", "Fira Code", monospace;
620  font-size: 0.88em;
621}
622body.light code { background: rgba(0,0,0,0.06); }
623
624/* ── Thinking block (collapsible) ──────────────────────────────── */
625.thinking-block {
626  border: 1px dashed rgba(255,255,255,0.15);
627  border-radius: 6px;
628  padding: 0.5rem 0.75rem;
629  margin: 0.4rem 0;
630  font-size: 0.88rem;
631}
632body.light .thinking-block { border-color: rgba(0,0,0,0.15); }
633.thinking-block summary {
634  cursor: pointer;
635  color: #bb9af7;
636  font-weight: 600;
637  user-select: none;
638}
639body.light .thinking-block summary { color: #7c3aed; }
640.think-content {
641  margin-top: 0.4rem;
642  padding-top: 0.4rem;
643  border-top: 1px dashed rgba(255,255,255,0.1);
644  opacity: 0.8;
645}
646body.light .think-content { border-color: rgba(0,0,0,0.08); }
647
648/* ── Tool call / result ────────────────────────────────────────── */
649.tool-call, .tool-result {
650  border-radius: 5px;
651  padding: 0.4rem 0.75rem;
652  margin: 0.3rem 0;
653  font-size: 0.88rem;
654  font-family: monospace;
655}
656body.dark .tool-call  { background: #2d1f3d; border-left: 3px solid #bb9af7; }
657body.dark .tool-result { background: #1a2d2d; border-left: 3px solid #73daca; }
658body.light .tool-call  { background: #faf5ff; border-left: 3px solid #a78bfa; }
659body.light .tool-result { background: #f0fdfa; border-left: 3px solid #14b8a6; }
660
661/* ── Links ─────────────────────────────────────────────────────── */
662a { color: #7aa2f7; text-decoration: underline; }
663body.light a { color: #2563eb; }
664"#;
665
666// ── Embedded JS ──────────────────────────────────────────────────────
667
668const JS: &str = r#"
669function toggleTheme() {
670  const body = document.body;
671  const isDark = body.classList.contains('dark');
672  body.classList.toggle('dark', !isDark);
673  body.classList.toggle('light', isDark);
674
675  // Swap highlight.js stylesheet
676  const darkSheet = document.getElementById('hljs-dark');
677  const lightSheet = document.getElementById('hljs-light');
678  if (darkSheet && lightSheet) {
679    darkSheet.disabled = isDark;
680    lightSheet.disabled = !isDark;
681  }
682}
683
684// Apply syntax highlighting
685document.addEventListener('DOMContentLoaded', () => {
686  document.querySelectorAll('pre code').forEach((block) => {
687    hljs.highlightElement(block);
688  });
689});
690"#;
691
692// ══════════════════════════════════════════════════════════════════════
693// Tests
694// ══════════════════════════════════════════════════════════════════════
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::session::AgentMessage;
700
701    fn make_entry(msg: AgentMessage) -> SessionEntry {
702        SessionEntry {
703            id: Uuid::new_v4(),
704            parent_id: None,
705            message: msg,
706            label: None,
707            timestamp: 1_700_000_000_000,
708        }
709    }
710
711    #[test]
712    fn export_produces_valid_html_structure() {
713        let entries = vec![
714            make_entry(AgentMessage::User {
715                content: "Hello".into(),
716            }),
717            make_entry(AgentMessage::Assistant {
718                content: "Hi there!".into(),
719            }),
720        ];
721        let meta = ExportMeta::default();
722        let html = export_html(&entries, &meta, None, None).unwrap();
723
724        assert!(html.starts_with("<!DOCTYPE html>"));
725        assert!(html.contains("<html"));
726        assert!(html.contains("</html>"));
727        assert!(html.contains("<head>"));
728        assert!(html.contains("</head>"));
729        assert!(html.contains("<body"));
730        assert!(html.contains("</body>"));
731        // Contains both messages
732        assert!(html.contains("msg-user"));
733        assert!(html.contains("msg-assistant"));
734        assert!(html.contains("You"));
735        assert!(html.contains("Assistant"));
736        assert!(html.contains("Hello"));
737        assert!(html.contains("Hi there!"));
738    }
739
740    #[test]
741    fn export_renders_thinking_block_collapsible() {
742        let entries = vec![make_entry(AgentMessage::Assistant {
743            content: "<think\nLet me reason step by step.\n</think\n\nThe answer is 42.".into(),
744        })];
745        let meta = ExportMeta::default();
746        let html = export_html(&entries, &meta, None, None).unwrap();
747
748        assert!(html.contains("<details class=\"thinking-block\">"));
749        assert!(html.contains("<summary>💭 Thinking</summary>"));
750        assert!(html.contains("Let me reason step by step."));
751        assert!(html.contains("The answer is 42."));
752    }
753
754    #[test]
755    fn export_includes_metadata_header() {
756        let entries = vec![];
757        let meta = ExportMeta {
758            model: Some("claude-sonnet-4".into()),
759            provider: Some("anthropic".into()),
760            exported_at: 1_700_000_000_000,
761            total_user_tokens: Some(120),
762            total_assistant_tokens: Some(350),
763        };
764        let html = export_html(&entries, &meta, None, None).unwrap();
765
766        assert!(html.contains("claude-sonnet-4"));
767        assert!(html.contains("anthropic"));
768        assert!(html.contains("120"));
769        assert!(html.contains("350"));
770        assert!(html.contains("User Tokens"));
771        assert!(html.contains("Assistant Tokens"));
772    }
773
774    #[test]
775    fn export_renders_code_block_with_language_class() {
776        let entries = vec![make_entry(AgentMessage::Assistant {
777            content: "Here is some code:\n```rust\nfn main() {\n    println!(\"hi\");\n}\n```\nDone.".into(),
778        })];
779        let meta = ExportMeta::default();
780        let html = export_html(&entries, &meta, None, None).unwrap();
781
782        assert!(html.contains("language-rust"));
783        assert!(html.contains("fn main()"));
784        assert!(html.contains("println!"));
785    }
786
787    #[test]
788    fn export_renders_tool_calls_and_results() {
789        let entries = vec![make_entry(AgentMessage::Assistant {
790            content: "🔧 Running bash\n```\nls -la\n```\n📤 result:\nfile1.txt\nfile2.txt".into(),
791        })];
792        let meta = ExportMeta::default();
793        let html = export_html(&entries, &meta, None, None).unwrap();
794
795        assert!(html.contains("tool-call"));
796        assert!(html.contains("tool-result"));
797    }
798
799    #[test]
800    fn export_renders_session_tree_navigation() {
801        let tree = TreeNode {
802            session_id: Uuid::new_v4(),
803            name: Some("root session".into()),
804            is_current: false,
805            children: vec![TreeNode {
806                session_id: Uuid::new_v4(),
807                name: Some("branch-1".into()),
808                is_current: true,
809                children: vec![],
810            }],
811        };
812        let meta = ExportMeta::default();
813        let html = export_html(&[], &meta, None, Some(&tree)).unwrap();
814
815        assert!(html.contains("tree-nav"));
816        assert!(html.contains("tree-current"));
817        assert!(html.contains("root session"));
818        assert!(html.contains("branch-1"));
819    }
820
821    #[test]
822    fn export_dark_theme_default_with_toggle() {
823        let meta = ExportMeta::default();
824        let html = export_html(&[], &meta, None, None).unwrap();
825
826        assert!(html.contains("class=\"dark\""));
827        assert!(html.contains("toggleTheme"));
828        assert!(html.contains("theme-toggle"));
829    }
830
831    #[test]
832    fn markdown_renders_bold_and_italic() {
833        let result = render_markdown("This is **bold** and *italic* text.");
834        assert!(result.contains("<strong>bold</strong>"));
835        assert!(result.contains("<em>italic</em>"));
836    }
837
838    #[test]
839    fn markdown_renders_inline_code() {
840        let result = render_markdown("Use `cargo build` to compile.");
841        assert!(result.contains("<code>cargo build</code>"));
842    }
843
844    #[test]
845    fn markdown_renders_links() {
846        let result = render_markdown("See [docs](https://example.com) for info.");
847        assert!(result.contains("<a href=\"https://example.com\">docs</a>"));
848    }
849
850    #[test]
851    fn html_escape_prevents_xss() {
852        let escaped = html_escape("<script>alert('xss')</script>");
853        assert!(!escaped.contains('<'));
854        assert!(escaped.contains("&lt;script"));
855    }
856}