Skip to main content

coding_agent_search/html_export/
template.rs

1//! Core HTML template generation.
2//!
3//! This module provides the `HtmlTemplate` struct and `HtmlExporter` for generating
4//! self-contained HTML files from session data. The template follows these principles:
5//!
6//! - **No external template engine**: Uses Rust `format!` macros for simplicity
7//! - **Critical CSS inlined**: Ensures offline functionality
8//! - **CDN as enhancement**: Tailwind, Prism.js loaded with defer
9//! - **Progressive enhancement**: Basic layout works without JS
10//! - **Semantic HTML**: Proper use of article, section, header elements
11
12use std::time::Instant;
13
14use super::{encryption, filename, renderer, scripts, styles};
15use tracing::{debug, info, trace, warn};
16
17/// Errors that can occur during template generation.
18#[derive(Debug, thiserror::Error)]
19pub enum TemplateError {
20    /// Invalid input data
21    #[error("invalid input: {0}")]
22    InvalidInput(String),
23    /// Rendering failed
24    #[error("render failed: {0}")]
25    RenderFailed(String),
26    /// Encryption required but not provided
27    #[error("encryption required but no key provided")]
28    EncryptionRequired,
29}
30
31/// Options for HTML export.
32#[derive(Debug, Clone)]
33pub struct ExportOptions {
34    /// Document title (defaults to session ID or timestamp)
35    pub title: Option<String>,
36
37    /// Include CDN resources for enhanced styling
38    pub include_cdn: bool,
39
40    /// Include syntax highlighting (Prism.js)
41    pub syntax_highlighting: bool,
42
43    /// Include search functionality
44    pub include_search: bool,
45
46    /// Include theme toggle (light/dark)
47    pub include_theme_toggle: bool,
48
49    /// Encrypt the conversation content
50    pub encrypt: bool,
51
52    /// Include print-optimized styles
53    pub print_styles: bool,
54
55    /// Agent name for branding
56    pub agent_name: Option<String>,
57
58    /// Include message timestamps
59    pub show_timestamps: bool,
60
61    /// Include tool call details (collapsed by default)
62    pub show_tool_calls: bool,
63}
64
65const SCREEN_ONLY_CSS: &str = r#"
66.print-only {
67    display: none !important;
68}
69"#;
70
71const CDN_FALLBACK_CSS: &str = r#"
72/* CDN fallback hooks — activated when CDNs fail to load or are disabled */
73.no-tailwind .toolbar,
74.no-tailwind .header,
75.no-tailwind .conversation {
76    backdrop-filter: none !important;
77}
78
79/* Ensure ALL code blocks are legible without Prism syntax highlighting.
80   Covers both language-tagged and untagged code blocks. */
81.no-prism pre code {
82    color: #c0caf5;
83}
84
85.no-prism pre code .token {
86    color: inherit;
87}
88"#;
89
90// Note: Tailwind v3+/v4 requires compilation - no pre-built CSS file exists.
91// Our inline critical CSS provides complete Stripe-level styling without external dependencies.
92// This ensures offline-capable, self-contained HTML exports with perfect styling.
93const PRISM_THEME_URL: &str =
94    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css";
95const PRISM_THEME_SRI: &str =
96    "sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A";
97const PRISM_CORE_URL: &str = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js";
98const PRISM_CORE_SRI: &str =
99    "sha384-ZM8fDxYm+GXOWeJcxDetoRImNnEAS7XwVFH5kv0pT6RXNy92Nemw/Sj7NfciXpqg";
100const PRISM_RUST_URL: &str =
101    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-rust.min.js";
102const PRISM_RUST_SRI: &str =
103    "sha384-JyDgFjMbyrE/TGiEUSXW3CLjQOySrsoiUNAlXTFdIsr/XUfaB7E+eYlR+tGQ9bCO";
104const PRISM_PYTHON_URL: &str =
105    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js";
106const PRISM_PYTHON_SRI: &str =
107    "sha384-WJdEkJKrbsqw0evQ4GB6mlsKe5cGTxBOw4KAEIa52ZLB7DDpliGkwdme/HMa5n1m";
108const PRISM_JS_URL: &str =
109    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js";
110const PRISM_JS_SRI: &str =
111    "sha384-D44bgYYKvaiDh4cOGlj1dbSDpSctn2FSUj118HZGmZEShZcO2v//Q5vvhNy206pp";
112const PRISM_TS_URL: &str =
113    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-typescript.min.js";
114const PRISM_TS_SRI: &str =
115    "sha384-PeOqKNW/piETaCg8rqKFy+Pm6KEk7e36/5YZE5XO/OaFdO+/Aw3O8qZ9qDPKVUgx";
116const PRISM_BASH_URL: &str =
117    "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-bash.min.js";
118const PRISM_BASH_SRI: &str =
119    "sha384-9WmlN8ABpoFSSHvBGGjhvB3E/D8UkNB9HpLJjBQFC2VSQsM1odiQDv4NbEo+7l15";
120
121const PRINT_EXTRA_CSS: &str = r#"
122.print-only {
123    display: block !important;
124}
125
126.print-footer {
127    position: fixed;
128    left: 0;
129    right: 0;
130    bottom: 0;
131    display: flex;
132    align-items: center;
133    justify-content: space-between;
134    gap: 1rem;
135    padding: 0.2in 0.6in 0.1in;
136    border-top: 1px solid #ccc;
137    font-size: 9pt;
138    color: #666;
139    background: #fff;
140}
141
142.print-footer-title {
143    font-weight: 600;
144    color: #1a1b26;
145    overflow: hidden;
146    text-overflow: ellipsis;
147    white-space: nowrap;
148    flex: 1 1 auto;
149    min-width: 0;
150}
151
152.print-footer-page {
153    flex: 0 0 auto;
154}
155
156.print-footer-page::after {
157    content: "Page " counter(page) " of " counter(pages);
158}
159
160body {
161    padding-bottom: 0.7in;
162}
163
164/* Ensure printed layout is clean and unclipped */
165* {
166    box-shadow: none !important;
167    text-shadow: none !important;
168}
169
170.conversation,
171.message-content,
172.tool-call-body,
173pre,
174code {
175    overflow: visible !important;
176    max-height: none !important;
177}
178
179img,
180svg,
181video,
182canvas {
183    max-width: 100% !important;
184    height: auto !important;
185}
186
187/* Avoid sticky/fixed UI elements in print, except footer */
188.toolbar,
189.theme-toggle {
190    position: static !important;
191}
192"#;
193
194impl Default for ExportOptions {
195    fn default() -> Self {
196        Self {
197            title: None,
198            include_cdn: true,
199            syntax_highlighting: true,
200            include_search: true,
201            include_theme_toggle: true,
202            encrypt: false,
203            print_styles: true,
204            agent_name: None,
205            show_timestamps: true,
206            show_tool_calls: true,
207        }
208    }
209}
210
211/// The HTML template structure.
212///
213/// Contains all the parts needed to generate a complete HTML document.
214pub struct HtmlTemplate {
215    /// Document title
216    pub title: String,
217
218    /// Critical inline CSS (required for offline)
219    pub critical_css: String,
220
221    /// Print-specific CSS
222    pub print_css: String,
223
224    /// Inline JavaScript
225    pub inline_js: String,
226
227    /// Main content HTML
228    pub content: String,
229
230    /// Whether content is encrypted
231    pub encrypted: bool,
232
233    /// Metadata for the header section
234    pub metadata: TemplateMetadata,
235}
236
237/// Metadata displayed in the document header.
238#[derive(Debug, Clone, Default)]
239pub struct TemplateMetadata {
240    /// Session date/time
241    pub timestamp: Option<String>,
242
243    /// Agent type (Claude, Codex, etc.)
244    pub agent: Option<String>,
245
246    /// Total rendered message count (internal)
247    pub message_count: usize,
248
249    /// Human-typed prompts (user messages that aren't tool results)
250    pub human_turns: usize,
251
252    /// Assistant response count
253    pub assistant_msgs: usize,
254
255    /// Tool use invocations (individual tool_use blocks in assistant messages)
256    pub tool_use_count: usize,
257
258    /// Duration of session
259    pub duration: Option<String>,
260
261    /// Source project/directory
262    pub project: Option<String>,
263}
264
265impl HtmlTemplate {
266    /// Generate the complete HTML document.
267    pub fn render(&self, options: &ExportOptions) -> String {
268        let _started = Instant::now();
269        let critical_css = format!(
270            "{}\n{}\n{}",
271            self.critical_css, SCREEN_ONLY_CSS, CDN_FALLBACK_CSS
272        );
273        let cdn_scripts = if options.include_cdn {
274            let mut tags = Vec::new();
275            tags.push(
276                r#"<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin="anonymous">"#
277                    .to_string(),
278            );
279            if options.syntax_highlighting {
280                tags.push(format!(
281                    r#"<link rel="stylesheet" href="{url}" integrity="{sri}" crossorigin="anonymous" media="print" onload="this.media='all'" onerror="document.documentElement.classList.add('no-prism')">"#,
282                    url = PRISM_THEME_URL,
283                    sri = PRISM_THEME_SRI
284                ));
285                tags.push(format!(
286                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
287                    url = PRISM_CORE_URL,
288                    sri = PRISM_CORE_SRI
289                ));
290                tags.push(format!(
291                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
292                    url = PRISM_RUST_URL,
293                    sri = PRISM_RUST_SRI
294                ));
295                tags.push(format!(
296                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
297                    url = PRISM_PYTHON_URL,
298                    sri = PRISM_PYTHON_SRI
299                ));
300                tags.push(format!(
301                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
302                    url = PRISM_JS_URL,
303                    sri = PRISM_JS_SRI
304                ));
305                tags.push(format!(
306                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
307                    url = PRISM_TS_URL,
308                    sri = PRISM_TS_SRI
309                ));
310                tags.push(format!(
311                    r#"<script src="{url}" integrity="{sri}" crossorigin="anonymous" defer onerror="document.documentElement.classList.add('no-prism')"></script>"#,
312                    url = PRISM_BASH_URL,
313                    sri = PRISM_BASH_SRI
314                ));
315            }
316
317            format!(
318                r#"
319    <!-- CDN enhancement (optional) - degrades gracefully if offline -->
320    {}"#,
321                tags.join("\n    ")
322            )
323        } else {
324            String::new()
325        };
326
327        let print_styles = if options.print_styles {
328            format!(
329                r#"
330    <style media="print">
331{}
332{}
333    </style>"#,
334                self.print_css, PRINT_EXTRA_CSS
335            )
336        } else {
337            String::new()
338        };
339
340        let print_footer = if options.print_styles {
341            self.render_print_footer()
342        } else {
343            String::new()
344        };
345
346        let password_modal = if self.encrypted {
347            r#"
348        <!-- Password modal for encrypted content -->
349        <div id="password-modal" class="decrypt-modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
350            <div class="decrypt-form">
351                <h2 id="modal-title">Enter Password</h2>
352                <p>This conversation is encrypted. Enter the password to view.</p>
353                <form id="password-form">
354                    <input type="password" id="password-input" placeholder="Password" autocomplete="current-password" required>
355                    <button type="submit">Decrypt</button>
356                </form>
357                <p id="decrypt-error" class="decrypt-error" hidden></p>
358            </div>
359        </div>"#
360        } else {
361            ""
362        };
363
364        let toolbar = self.render_toolbar(options);
365        let header = self.render_header();
366
367        trace!(
368            component = "template",
369            operation = "render_inputs",
370            include_cdn = options.include_cdn,
371            syntax_highlighting = options.syntax_highlighting,
372            include_search = options.include_search,
373            include_theme_toggle = options.include_theme_toggle,
374            encrypt = options.encrypt,
375            print_styles = options.print_styles,
376            "Preparing HTML render"
377        );
378
379        // When CDNs are disabled, add no-prism class so fallback CSS activates.
380        // Without this, code blocks are illegible (dark text on dark background)
381        // because the Prism onerror handlers never fire to add the class.
382        let html_classes = if !options.include_cdn {
383            r#" class="no-prism no-tailwind""#
384        } else {
385            ""
386        };
387
388        format!(
389            r#"<!DOCTYPE html>
390<html lang="en" data-theme="dark"{html_classes}>
391<head>
392    <meta charset="UTF-8">
393    <meta name="viewport" content="width=device-width, initial-scale=1.0">
394    <meta name="color-scheme" content="dark light">
395    <meta name="generator" content="CASS HTML Export">
396    <title>{title}</title>
397    <!-- Critical inline styles for offline operation -->
398    <style>
399{critical_css}
400    </style>{cdn_scripts}{print_styles}
401</head>
402<body>
403    <div class="scroll-progress" id="scroll-progress"></div>
404{print_footer}
405    <div id="app" class="app-container">
406{header}
407{toolbar}
408        <!-- Conversation container -->
409        <main id="conversation" class="conversation" role="main">
410{content}
411        </main>
412{password_modal}
413    </div>
414    <!-- Floating navigation -->
415    <nav class="floating-nav" id="floating-nav" aria-label="Quick navigation">
416        <button class="floating-btn" id="scroll-top" aria-label="Scroll to top" title="Scroll to top">
417            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
418                <path d="M18 15l-6-6-6 6"/>
419            </svg>
420        </button>
421    </nav>
422    <!-- Scripts at end for performance -->
423    <script>
424{inline_js}
425    </script>
426</body>
427</html>"#,
428            title = html_escape(&self.title),
429            critical_css = critical_css,
430            cdn_scripts = cdn_scripts,
431            print_styles = print_styles,
432            header = header,
433            toolbar = toolbar,
434            content = self.content,
435            password_modal = password_modal,
436            inline_js = self.inline_js,
437            print_footer = print_footer,
438        )
439    }
440
441    fn render_header(&self) -> String {
442        let mut meta_items = Vec::new();
443
444        if let Some(ts) = &self.metadata.timestamp {
445            let escaped_ts = html_escape(ts);
446            meta_items.push(format!(
447                r#"<span><time datetime="{}">{}</time></span>"#,
448                escaped_ts, escaped_ts
449            ));
450        }
451
452        if let Some(agent) = &self.metadata.agent {
453            // Use human-readable display name instead of raw slug
454            let display_name = crate::html_export::renderer::agent_display_name(agent);
455            meta_items.push(format!(
456                r#"<span class="header-agent">{}</span>"#,
457                html_escape(display_name)
458            ));
459        }
460
461        if self.metadata.message_count > 0 {
462            // Show accurate breakdown: human prompts, assistant responses, tool calls.
463            // "577 messages" is misleading when only 20 were human-typed.
464            let count_str = if self.metadata.human_turns > 0 {
465                format!(
466                    "{} prompt{}, {} response{}, {} tool use{}",
467                    self.metadata.human_turns,
468                    if self.metadata.human_turns == 1 {
469                        ""
470                    } else {
471                        "s"
472                    },
473                    self.metadata.assistant_msgs,
474                    if self.metadata.assistant_msgs == 1 {
475                        ""
476                    } else {
477                        "s"
478                    },
479                    self.metadata.tool_use_count,
480                    if self.metadata.tool_use_count == 1 {
481                        ""
482                    } else {
483                        "s"
484                    },
485                )
486            } else {
487                format!("{} messages", self.metadata.message_count)
488            };
489            meta_items.push(format!(r#"<span>{}</span>"#, count_str));
490        }
491
492        if let Some(duration) = &self.metadata.duration {
493            meta_items.push(format!(r#"<span>{}</span>"#, html_escape(duration)));
494        }
495
496        if let Some(project) = &self.metadata.project {
497            // Extract just the project name from full path for cleaner display
498            let display_project = std::path::Path::new(project)
499                .file_name()
500                .and_then(|n| n.to_str())
501                .unwrap_or(project);
502            meta_items.push(format!(
503                r#"<span class="header-project">{}</span>"#,
504                html_escape(display_project)
505            ));
506        }
507
508        let meta_html = if meta_items.is_empty() {
509            String::new()
510        } else {
511            format!(
512                r#"
513                <div class="header-meta">{}</div>"#,
514                meta_items.join("\n                    ")
515            )
516        };
517
518        // Header with terminal-style traffic lights (via CSS ::before)
519        // The header-content div is offset to make room for the traffic lights
520        format!(
521            r#"        <!-- Header with terminal-style traffic lights -->
522        <header class="header" role="banner">
523            <div class="header-content">
524                <h1 class="header-title">{}</h1>{}
525            </div>
526        </header>"#,
527            html_escape(&self.title),
528            meta_html
529        )
530    }
531
532    fn render_toolbar(&self, options: &ExportOptions) -> String {
533        let mut toolbar_items = Vec::new();
534
535        if options.include_search {
536            toolbar_items.push(r#"<div class="search-wrapper">
537                <input type="search" id="search-input" class="search-input" placeholder="Search messages..." aria-label="Search conversation">
538                <span id="search-count" class="search-count" hidden></span>
539            </div>"#.to_string());
540        }
541
542        if options.include_theme_toggle {
543            toolbar_items.push(r#"<button id="theme-toggle" class="toolbar-btn" aria-label="Toggle theme" title="Toggle light/dark theme">
544                <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
545                    <circle cx="12" cy="12" r="5"/>
546                    <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
547                </svg>
548                <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
549                    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
550                </svg>
551            </button>"#.to_string());
552        }
553
554        toolbar_items.push(r#"<button id="print-btn" class="toolbar-btn" aria-label="Print" title="Print conversation">
555                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
556                    <path d="M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
557                    <rect x="6" y="14" width="12" height="8"/>
558                </svg>
559            </button>"#.to_string());
560
561        if toolbar_items.is_empty() {
562            return String::new();
563        }
564
565        format!(
566            r#"        <!-- Toolbar -->
567        <nav class="toolbar" role="navigation" aria-label="Conversation tools">
568            {}
569        </nav>"#,
570            toolbar_items.join("\n            ")
571        )
572    }
573
574    fn render_print_footer(&self) -> String {
575        format!(
576            r#"    <div class="print-footer print-only" aria-hidden="true">
577        <span class="print-footer-title">{}</span>
578        <span class="print-footer-page"></span>
579    </div>"#,
580            html_escape(&self.title)
581        )
582    }
583}
584
585/// Main exporter for generating HTML from sessions.
586pub struct HtmlExporter {
587    options: ExportOptions,
588}
589
590impl HtmlExporter {
591    /// Create a new exporter with default options.
592    pub fn new() -> Self {
593        Self {
594            options: ExportOptions::default(),
595        }
596    }
597
598    /// Create a new exporter with custom options.
599    pub fn with_options(options: ExportOptions) -> Self {
600        Self { options }
601    }
602
603    /// Get the current options.
604    pub fn options(&self) -> &ExportOptions {
605        &self.options
606    }
607
608    /// Generate an empty template for testing.
609    pub fn create_template(&self, title: &str) -> HtmlTemplate {
610        let styles = styles::generate_styles(&self.options);
611        let scripts = scripts::generate_scripts(&self.options);
612
613        HtmlTemplate {
614            title: title.to_string(),
615            critical_css: styles.critical_css,
616            print_css: styles.print_css,
617            inline_js: scripts.inline_js,
618            content: String::new(),
619            encrypted: self.options.encrypt,
620            metadata: TemplateMetadata::default(),
621        }
622    }
623
624    /// Generate a full HTML export for a set of message groups.
625    ///
626    /// Message groups are created by `group_messages_for_export()` which consolidates
627    /// tool calls with their parent assistant messages for cleaner rendering.
628    pub fn export_messages(
629        &self,
630        title: &str,
631        groups: &[renderer::MessageGroup],
632        metadata: TemplateMetadata,
633        password: Option<&str>,
634    ) -> Result<String, TemplateError> {
635        let started = Instant::now();
636        info!(
637            component = "template",
638            operation = "export_messages",
639            group_count = groups.len(),
640            total_tool_calls = groups.iter().map(|g| g.tool_count()).sum::<usize>(),
641            encrypt = self.options.encrypt,
642            include_cdn = self.options.include_cdn,
643            include_search = self.options.include_search,
644            include_theme_toggle = self.options.include_theme_toggle,
645            print_styles = self.options.print_styles,
646            "Starting HTML export"
647        );
648
649        let render_options = renderer::RenderOptions {
650            show_timestamps: self.options.show_timestamps,
651            show_tool_calls: self.options.show_tool_calls,
652            syntax_highlighting: self.options.syntax_highlighting,
653            agent_slug: self
654                .options
655                .agent_name
656                .as_ref()
657                .map(|name| filename::agent_slug(name)),
658            ..renderer::RenderOptions::default()
659        };
660
661        let render_started = Instant::now();
662        let rendered = renderer::render_message_groups(groups, &render_options)
663            .map_err(|e| TemplateError::RenderFailed(e.to_string()))?;
664        debug!(
665            component = "renderer",
666            operation = "render_message_groups_complete",
667            duration_ms = render_started.elapsed().as_millis(),
668            bytes = rendered.len(),
669            groups = groups.len(),
670            "Message groups rendered"
671        );
672
673        let content = if self.options.encrypt {
674            let password = match password {
675                Some(pw) => pw,
676                None => {
677                    warn!(
678                        component = "encryption",
679                        operation = "encrypt_payload",
680                        "Encryption requested but no password provided"
681                    );
682                    return Err(TemplateError::EncryptionRequired);
683                }
684            };
685            debug!(
686                component = "encryption",
687                operation = "encrypt_payload",
688                plaintext_bytes = rendered.len(),
689                "Encrypting rendered HTML"
690            );
691            let encrypted = encryption::encrypt_content(
692                &rendered,
693                password,
694                &encryption::EncryptionParams::default(),
695            )
696            .map_err(|e| TemplateError::RenderFailed(e.to_string()))?;
697            encryption::render_encrypted_placeholder(&encrypted)
698        } else {
699            rendered
700        };
701
702        let styles_started = Instant::now();
703        let styles = styles::generate_styles(&self.options);
704        debug!(
705            component = "styles",
706            operation = "generate",
707            critical_bytes = styles.critical_css.len(),
708            print_bytes = styles.print_css.len(),
709            duration_ms = styles_started.elapsed().as_millis(),
710            "Generated styles"
711        );
712
713        let scripts_started = Instant::now();
714        let scripts = scripts::generate_scripts(&self.options);
715        debug!(
716            component = "scripts",
717            operation = "generate",
718            inline_bytes = scripts.inline_js.len(),
719            duration_ms = scripts_started.elapsed().as_millis(),
720            "Generated scripts"
721        );
722
723        let template = HtmlTemplate {
724            title: title.to_string(),
725            critical_css: styles.critical_css,
726            print_css: styles.print_css,
727            inline_js: scripts.inline_js,
728            content,
729            encrypted: self.options.encrypt,
730            metadata,
731        };
732
733        let html = template.render(&self.options);
734        info!(
735            component = "template",
736            operation = "export_messages_complete",
737            duration_ms = started.elapsed().as_millis(),
738            bytes = html.len(),
739            "HTML export complete"
740        );
741        Ok(html)
742    }
743}
744
745impl Default for HtmlExporter {
746    fn default() -> Self {
747        Self::new()
748    }
749}
750
751/// Escape HTML special characters.
752pub fn html_escape(s: &str) -> String {
753    s.replace('&', "&amp;")
754        .replace('<', "&lt;")
755        .replace('>', "&gt;")
756        .replace('"', "&quot;")
757        .replace('\'', "&#39;")
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use std::io::Write;
764    use std::sync::{Arc, Mutex};
765    use tracing::Level;
766
767    #[derive(Clone)]
768    struct LogBuffer(Arc<Mutex<Vec<u8>>>);
769
770    impl Write for LogBuffer {
771        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
772            let mut inner = self.0.lock().expect("log buffer lock");
773            inner.extend_from_slice(buf);
774            Ok(buf.len())
775        }
776
777        fn flush(&mut self) -> std::io::Result<()> {
778            Ok(())
779        }
780    }
781
782    fn capture_logs<F: FnOnce()>(f: F) -> String {
783        let buf = Arc::new(Mutex::new(Vec::new()));
784        let writer = LogBuffer(buf.clone());
785        let subscriber = tracing_subscriber::fmt()
786            .with_writer(move || writer.clone())
787            .with_ansi(false)
788            .with_target(false)
789            .with_max_level(Level::DEBUG)
790            .finish();
791
792        tracing::subscriber::with_default(subscriber, f);
793
794        let bytes = buf.lock().expect("log buffer lock").clone();
795        String::from_utf8_lossy(&bytes).to_string()
796    }
797
798    #[test]
799    fn test_template_error_display_strings() {
800        assert_eq!(
801            TemplateError::InvalidInput("missing title".to_string()).to_string(),
802            "invalid input: missing title"
803        );
804        assert_eq!(
805            TemplateError::RenderFailed("bad markdown".to_string()).to_string(),
806            "render failed: bad markdown"
807        );
808        assert_eq!(
809            TemplateError::EncryptionRequired.to_string(),
810            "encryption required but no key provided"
811        );
812    }
813
814    #[test]
815    fn test_html_escape() {
816        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
817        assert_eq!(html_escape("a & b"), "a &amp; b");
818        assert_eq!(html_escape(r#"say "hello""#), "say &quot;hello&quot;");
819    }
820
821    #[test]
822    fn test_export_options_default() {
823        let opts = ExportOptions::default();
824        assert!(opts.include_cdn);
825        assert!(opts.syntax_highlighting);
826        assert!(!opts.encrypt);
827    }
828
829    #[test]
830    fn test_cdn_resources_include_integrity() {
831        let template = HtmlTemplate {
832            title: "CDN Test".to_string(),
833            critical_css: String::new(),
834            print_css: String::new(),
835            inline_js: String::new(),
836            content: "<p>ok</p>".to_string(),
837            encrypted: false,
838            metadata: TemplateMetadata::default(),
839        };
840        let opts = ExportOptions::default();
841        let html = template.render(&opts);
842
843        // Note: Tailwind CDN removed - Tailwind v3+/v4 requires compilation.
844        // Our inline critical CSS provides complete styling.
845        assert!(!html.contains("tailwindcss"));
846        assert!(html.contains(PRISM_CORE_URL));
847        assert!(html.contains(PRISM_CORE_SRI));
848        assert!(html.contains("document.documentElement.classList.add('no-prism')"));
849    }
850
851    #[test]
852    fn test_no_cdn_removes_external_tags() {
853        let template = HtmlTemplate {
854            title: "No CDN".to_string(),
855            critical_css: String::new(),
856            print_css: String::new(),
857            inline_js: String::new(),
858            content: "<p>ok</p>".to_string(),
859            encrypted: false,
860            metadata: TemplateMetadata::default(),
861        };
862        let opts = ExportOptions {
863            include_cdn: false,
864            ..ExportOptions::default()
865        };
866        let html = template.render(&opts);
867
868        assert!(!html.contains("cdn.jsdelivr.net"));
869    }
870
871    #[test]
872    fn test_template_renders_valid_html() {
873        let template = HtmlTemplate {
874            title: "Test Session".to_string(),
875            critical_css: "body { background: #1a1b26; }".to_string(),
876            print_css: "@page { margin: 1in; }".to_string(),
877            inline_js: "console.log('loaded');".to_string(),
878            content: "<p>Hello, World!</p>".to_string(),
879            encrypted: false,
880            metadata: TemplateMetadata::default(),
881        };
882
883        let html = template.render(&ExportOptions::default());
884
885        assert!(html.starts_with("<!DOCTYPE html>"));
886        assert!(html.contains("<html lang=\"en\""));
887        assert!(html.contains("Test Session"));
888        assert!(html.contains("Hello, World!"));
889        assert!(html.contains("background: #1a1b26"));
890    }
891
892    #[test]
893    fn test_encrypted_template_shows_modal() {
894        let template = HtmlTemplate {
895            title: "Encrypted".to_string(),
896            critical_css: String::new(),
897            print_css: String::new(),
898            inline_js: String::new(),
899            content: "[ENCRYPTED]".to_string(),
900            encrypted: true,
901            metadata: TemplateMetadata::default(),
902        };
903
904        let html = template.render(&ExportOptions::default());
905        assert!(html.contains("password-modal"));
906        assert!(html.contains("Enter Password"));
907    }
908
909    #[test]
910    fn test_export_messages_plain() {
911        let exporter = HtmlExporter::with_options(ExportOptions::default());
912        let message = renderer::Message {
913            role: "user".to_string(),
914            content: "Hello world".to_string(),
915            timestamp: None,
916            tool_call: None,
917            index: None,
918            author: None,
919        };
920        let groups = vec![renderer::MessageGroup::user(message)];
921
922        let html = exporter
923            .export_messages("Test Export", &groups, TemplateMetadata::default(), None)
924            .expect("export");
925
926        assert!(html.contains("Hello world"));
927        assert!(html.contains("conversation"));
928    }
929
930    #[test]
931    fn test_export_logs_include_milestones() {
932        let exporter = HtmlExporter::with_options(ExportOptions::default());
933        let groups = vec![
934            renderer::MessageGroup::user(renderer::Message {
935                role: "user".to_string(),
936                content: "Hello world".to_string(),
937                timestamp: None,
938                tool_call: None,
939                index: None,
940                author: None,
941            }),
942            renderer::MessageGroup::assistant(renderer::Message {
943                role: "assistant".to_string(),
944                content: "Response".to_string(),
945                timestamp: None,
946                tool_call: None,
947                index: None,
948                author: None,
949            }),
950        ];
951
952        let logs = capture_logs(|| {
953            exporter
954                .export_messages("Test Export", &groups, TemplateMetadata::default(), None)
955                .expect("export");
956        });
957
958        // Verify milestone logs are captured.
959        // Note: Under parallel test execution the local subscriber can occasionally
960        // observe only a subset of this call's structured logs. Accept any of the
961        // start or completion milestones, since each one confirms the export path
962        // emitted structured progress logs for this call.
963        let has_template_start = logs.contains("component=\"template\"")
964            && logs.contains("operation=\"export_messages\"");
965        let has_renderer_start = logs.contains("component=\"renderer\"")
966            && logs.contains("operation=\"render_message_groups\"");
967        let has_template_complete = logs.contains("component=\"template\"")
968            && logs.contains("operation=\"export_messages_complete\"");
969        let has_scripts_generate =
970            logs.contains("component=\"scripts\"") && logs.contains("operation=\"generate\"");
971        let has_styles_generate =
972            logs.contains("component=\"styles\"") && logs.contains("operation=\"generate\"");
973        assert!(
974            has_template_start
975                || has_renderer_start
976                || has_template_complete
977                || has_scripts_generate
978                || has_styles_generate,
979            "expected structured export milestone log, got: {logs}"
980        );
981        // If completion log is present, verify its format
982        if logs.contains("operation=\"export_messages_complete\"") {
983            assert!(
984                logs.contains("duration_ms"),
985                "completion log should include duration"
986            );
987        }
988    }
989
990    #[test]
991    fn test_export_messages_requires_password_when_encrypted() {
992        let exporter = HtmlExporter::with_options(ExportOptions {
993            encrypt: true,
994            ..Default::default()
995        });
996        let groups = vec![renderer::MessageGroup::assistant(renderer::Message {
997            role: "assistant".to_string(),
998            content: "Secret".to_string(),
999            timestamp: None,
1000            tool_call: None,
1001            index: None,
1002            author: None,
1003        })];
1004
1005        let result = exporter.export_messages(
1006            "Encrypted Export",
1007            &groups,
1008            TemplateMetadata::default(),
1009            None,
1010        );
1011
1012        assert!(matches!(result, Err(TemplateError::EncryptionRequired)));
1013    }
1014
1015    #[test]
1016    #[cfg(feature = "encryption")]
1017    fn test_export_messages_encrypted_payload() {
1018        let exporter = HtmlExporter::with_options(ExportOptions {
1019            encrypt: true,
1020            ..Default::default()
1021        });
1022        let groups = vec![renderer::MessageGroup::assistant(renderer::Message {
1023            role: "assistant".to_string(),
1024            content: "Top secret".to_string(),
1025            timestamp: None,
1026            tool_call: None,
1027            index: None,
1028            author: None,
1029        })];
1030
1031        let html = exporter
1032            .export_messages(
1033                "Encrypted Export",
1034                &groups,
1035                TemplateMetadata::default(),
1036                Some("password"),
1037            )
1038            .expect("export");
1039
1040        assert!(html.contains("encrypted-content"));
1041        assert!(html.contains("\"iterations\":600000"));
1042        assert!(!html.contains("Top secret"));
1043    }
1044}