1use std::time::Instant;
13
14use super::{encryption, filename, renderer, scripts, styles};
15use tracing::{debug, info, trace, warn};
16
17#[derive(Debug, thiserror::Error)]
19pub enum TemplateError {
20 #[error("invalid input: {0}")]
22 InvalidInput(String),
23 #[error("render failed: {0}")]
25 RenderFailed(String),
26 #[error("encryption required but no key provided")]
28 EncryptionRequired,
29}
30
31#[derive(Debug, Clone)]
33pub struct ExportOptions {
34 pub title: Option<String>,
36
37 pub include_cdn: bool,
39
40 pub syntax_highlighting: bool,
42
43 pub include_search: bool,
45
46 pub include_theme_toggle: bool,
48
49 pub encrypt: bool,
51
52 pub print_styles: bool,
54
55 pub agent_name: Option<String>,
57
58 pub show_timestamps: bool,
60
61 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
90const 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
211pub struct HtmlTemplate {
215 pub title: String,
217
218 pub critical_css: String,
220
221 pub print_css: String,
223
224 pub inline_js: String,
226
227 pub content: String,
229
230 pub encrypted: bool,
232
233 pub metadata: TemplateMetadata,
235}
236
237#[derive(Debug, Clone, Default)]
239pub struct TemplateMetadata {
240 pub timestamp: Option<String>,
242
243 pub agent: Option<String>,
245
246 pub message_count: usize,
248
249 pub human_turns: usize,
251
252 pub assistant_msgs: usize,
254
255 pub tool_use_count: usize,
257
258 pub duration: Option<String>,
260
261 pub project: Option<String>,
263}
264
265impl HtmlTemplate {
266 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 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 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 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 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 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
585pub struct HtmlExporter {
587 options: ExportOptions,
588}
589
590impl HtmlExporter {
591 pub fn new() -> Self {
593 Self {
594 options: ExportOptions::default(),
595 }
596 }
597
598 pub fn with_options(options: ExportOptions) -> Self {
600 Self { options }
601 }
602
603 pub fn options(&self) -> &ExportOptions {
605 &self.options
606 }
607
608 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 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
751pub fn html_escape(s: &str) -> String {
753 s.replace('&', "&")
754 .replace('<', "<")
755 .replace('>', ">")
756 .replace('"', """)
757 .replace('\'', "'")
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>"), "<script>");
817 assert_eq!(html_escape("a & b"), "a & b");
818 assert_eq!(html_escape(r#"say "hello""#), "say "hello"");
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 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 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 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}