Skip to main content

oxiphysics_io/
simulation_report_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Simulation report generation: HTML, Markdown, and LaTeX output.
5//!
6//! Provides structured report data types ([`SimulationReport`], [`ReportSection`],
7//! [`DataTable`]) and three writer implementations:
8//! - [`HtmlReportWriter`] — styled HTML with embedded CSS
9//! - [`MarkdownReportWriter`] — GitHub-flavored Markdown
10//! - [`LatexReportWriter`] — LaTeX `article` class with tabular environments
11
12#![allow(dead_code)]
13
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16
17// ---------------------------------------------------------------------------
18// ColumnType
19// ---------------------------------------------------------------------------
20
21/// Type of data stored in a [`DataTable`] column.
22#[derive(Debug, Clone, PartialEq)]
23pub enum ColumnType {
24    /// Integer column.
25    Integer,
26    /// Floating-point column.
27    Float,
28    /// Text column.
29    Text,
30    /// Boolean (true/false) column.
31    Boolean,
32}
33
34impl ColumnType {
35    /// Return a human-readable label for this column type.
36    pub fn label(&self) -> &'static str {
37        match self {
38            ColumnType::Integer => "integer",
39            ColumnType::Float => "float",
40            ColumnType::Text => "text",
41            ColumnType::Boolean => "boolean",
42        }
43    }
44}
45
46// ---------------------------------------------------------------------------
47// CellValue
48// ---------------------------------------------------------------------------
49
50/// A single table cell value.
51#[derive(Debug, Clone, PartialEq)]
52pub enum CellValue {
53    /// Integer cell.
54    Int(i64),
55    /// Float cell with optional precision (decimal places).
56    Float(f64, Option<usize>),
57    /// Text cell.
58    Text(String),
59    /// Boolean cell.
60    Bool(bool),
61    /// Empty / missing cell.
62    Empty,
63}
64
65impl CellValue {
66    /// Render the cell as a plain string.
67    pub fn to_display(&self) -> String {
68        match self {
69            CellValue::Int(v) => v.to_string(),
70            CellValue::Float(v, Some(prec)) => format!("{:.prec$}", v, prec = prec),
71            CellValue::Float(v, None) => format!("{v:.6}"),
72            CellValue::Text(s) => s.clone(),
73            CellValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
74            CellValue::Empty => String::new(),
75        }
76    }
77
78    /// Render the cell as an HTML-safe string (escapes `<`, `>`, `&`).
79    pub fn to_html(&self) -> String {
80        html_escape(&self.to_display())
81    }
82
83    /// Render the cell as a LaTeX-safe string (escapes special characters).
84    pub fn to_latex(&self) -> String {
85        latex_escape(&self.to_display())
86    }
87}
88
89// ---------------------------------------------------------------------------
90// DataTable
91// ---------------------------------------------------------------------------
92
93/// A rectangular table of [`CellValue`]s with a header row and typed columns.
94#[derive(Debug, Clone, Default)]
95pub struct DataTable {
96    /// Table caption / title.
97    pub caption: String,
98    /// Column header labels.
99    pub headers: Vec<String>,
100    /// Column types (must match `headers` length if non-empty).
101    pub column_types: Vec<ColumnType>,
102    /// Data rows; each row must have the same length as `headers`.
103    pub rows: Vec<Vec<CellValue>>,
104    /// Optional row highlighting: row index → CSS class or LaTeX command.
105    pub row_highlights: HashMap<usize, String>,
106}
107
108impl DataTable {
109    /// Create an empty table with the given headers.
110    pub fn new(caption: impl Into<String>, headers: Vec<String>) -> Self {
111        let ncols = headers.len();
112        Self {
113            caption: caption.into(),
114            headers,
115            column_types: vec![ColumnType::Text; ncols],
116            rows: Vec::new(),
117            row_highlights: HashMap::new(),
118        }
119    }
120
121    /// Append a row. Panics in debug builds if the row width doesn't match headers.
122    pub fn add_row(&mut self, row: Vec<CellValue>) {
123        debug_assert!(
124            self.headers.is_empty() || row.len() == self.headers.len(),
125            "row width {} != header count {}",
126            row.len(),
127            self.headers.len()
128        );
129        self.rows.push(row);
130    }
131
132    /// Set the column type for column `idx`.
133    pub fn set_column_type(&mut self, idx: usize, ct: ColumnType) {
134        if idx < self.column_types.len() {
135            self.column_types[idx] = ct;
136        }
137    }
138
139    /// Highlight row `idx` with a label (e.g. `"highlight"`, `"\\rowcolor{yellow}"`).
140    pub fn highlight_row(&mut self, idx: usize, label: impl Into<String>) {
141        self.row_highlights.insert(idx, label.into());
142    }
143
144    /// Number of data rows.
145    pub fn row_count(&self) -> usize {
146        self.rows.len()
147    }
148
149    /// Number of columns.
150    pub fn col_count(&self) -> usize {
151        self.headers.len()
152    }
153
154    /// Returns `true` if the table has no data rows.
155    pub fn is_empty(&self) -> bool {
156        self.rows.is_empty()
157    }
158
159    /// Compute the maximum display-string width of each column (including header).
160    pub fn column_widths(&self) -> Vec<usize> {
161        let n = self.headers.len();
162        let mut widths: Vec<usize> = self.headers.iter().map(|h| h.len()).collect();
163        for row in &self.rows {
164            for (i, cell) in row.iter().enumerate() {
165                if i < n {
166                    widths[i] = widths[i].max(cell.to_display().len());
167                }
168            }
169        }
170        widths
171    }
172}
173
174// ---------------------------------------------------------------------------
175// FigureRef
176// ---------------------------------------------------------------------------
177
178/// A reference to an external figure file embedded in a report section.
179#[derive(Debug, Clone)]
180pub struct FigureRef {
181    /// Unique label / id for the figure.
182    pub label: String,
183    /// File path or URL.
184    pub path: String,
185    /// Caption text.
186    pub caption: String,
187    /// Optional alt-text for HTML.
188    pub alt: Option<String>,
189    /// Optional width hint (e.g. `"80%"`, `"0.7\\linewidth"`).
190    pub width: Option<String>,
191}
192
193impl FigureRef {
194    /// Create a simple figure reference with just a path and caption.
195    pub fn new(
196        label: impl Into<String>,
197        path: impl Into<String>,
198        caption: impl Into<String>,
199    ) -> Self {
200        Self {
201            label: label.into(),
202            path: path.into(),
203            caption: caption.into(),
204            alt: None,
205            width: None,
206        }
207    }
208
209    /// Builder: set alt text.
210    pub fn with_alt(mut self, alt: impl Into<String>) -> Self {
211        self.alt = Some(alt.into());
212        self
213    }
214
215    /// Builder: set width hint.
216    pub fn with_width(mut self, width: impl Into<String>) -> Self {
217        self.width = Some(width.into());
218        self
219    }
220}
221
222// ---------------------------------------------------------------------------
223// ReportSection
224// ---------------------------------------------------------------------------
225
226/// A single section within a [`SimulationReport`].
227#[derive(Debug, Clone, Default)]
228pub struct ReportSection {
229    /// Section title.
230    pub title: String,
231    /// Introductory / body text (may contain multiple paragraphs separated by `\n\n`).
232    pub text: String,
233    /// Data tables contained in this section.
234    pub tables: Vec<DataTable>,
235    /// Figure references in this section.
236    pub figures: Vec<FigureRef>,
237    /// Subsection level (0 = top-level section, 1 = subsection, …).
238    pub level: u8,
239}
240
241impl ReportSection {
242    /// Create a new top-level section.
243    pub fn new(title: impl Into<String>) -> Self {
244        Self {
245            title: title.into(),
246            level: 0,
247            ..Default::default()
248        }
249    }
250
251    /// Create a subsection (level 1).
252    pub fn subsection(title: impl Into<String>) -> Self {
253        Self {
254            title: title.into(),
255            level: 1,
256            ..Default::default()
257        }
258    }
259
260    /// Append body text (separated from existing text by a blank line).
261    pub fn append_text(&mut self, text: impl Into<String>) {
262        let t = text.into();
263        if self.text.is_empty() {
264            self.text = t;
265        } else {
266            self.text.push_str("\n\n");
267            self.text.push_str(&t);
268        }
269    }
270
271    /// Add a data table to this section.
272    pub fn add_table(&mut self, table: DataTable) {
273        self.tables.push(table);
274    }
275
276    /// Add a figure reference to this section.
277    pub fn add_figure(&mut self, figure: FigureRef) {
278        self.figures.push(figure);
279    }
280}
281
282// ---------------------------------------------------------------------------
283// SimulationReport
284// ---------------------------------------------------------------------------
285
286/// A complete simulation report aggregating parameters, results, sections,
287/// figures, and tables.
288#[derive(Debug, Clone, Default)]
289pub struct SimulationReport {
290    /// Report title.
291    pub title: String,
292    /// Short description / abstract.
293    pub description: String,
294    /// Simulation parameters (key → value string).
295    pub parameters: HashMap<String, String>,
296    /// Key result values (key → value string).
297    pub results: HashMap<String, String>,
298    /// Ordered report sections.
299    pub sections: Vec<ReportSection>,
300    /// Top-level figure references (outside any section).
301    pub figures: Vec<FigureRef>,
302    /// Top-level data tables (outside any section).
303    pub tables: Vec<DataTable>,
304    /// Report author.
305    pub author: String,
306    /// Report date.
307    pub date: String,
308    /// Optional version string.
309    pub version: Option<String>,
310}
311
312impl SimulationReport {
313    /// Create a new empty report with the given title.
314    pub fn new(title: impl Into<String>) -> Self {
315        Self {
316            title: title.into(),
317            ..Default::default()
318        }
319    }
320
321    /// Set the description / abstract.
322    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
323        self.description = desc.into();
324        self
325    }
326
327    /// Set the author.
328    pub fn with_author(mut self, author: impl Into<String>) -> Self {
329        self.author = author.into();
330        self
331    }
332
333    /// Set the date string.
334    pub fn with_date(mut self, date: impl Into<String>) -> Self {
335        self.date = date.into();
336        self
337    }
338
339    /// Set the version string.
340    pub fn with_version(mut self, version: impl Into<String>) -> Self {
341        self.version = Some(version.into());
342        self
343    }
344
345    /// Insert a parameter key-value pair.
346    pub fn add_parameter(&mut self, key: impl Into<String>, value: impl Into<String>) {
347        self.parameters.insert(key.into(), value.into());
348    }
349
350    /// Insert a result key-value pair.
351    pub fn add_result(&mut self, key: impl Into<String>, value: impl Into<String>) {
352        self.results.insert(key.into(), value.into());
353    }
354
355    /// Append a report section.
356    pub fn add_section(&mut self, section: ReportSection) {
357        self.sections.push(section);
358    }
359
360    /// Append a top-level figure.
361    pub fn add_figure(&mut self, figure: FigureRef) {
362        self.figures.push(figure);
363    }
364
365    /// Append a top-level table.
366    pub fn add_table(&mut self, table: DataTable) {
367        self.tables.push(table);
368    }
369
370    /// Total number of tables across all sections and top-level.
371    pub fn total_tables(&self) -> usize {
372        self.tables.len() + self.sections.iter().map(|s| s.tables.len()).sum::<usize>()
373    }
374
375    /// Total number of figures across all sections and top-level.
376    pub fn total_figures(&self) -> usize {
377        self.figures.len() + self.sections.iter().map(|s| s.figures.len()).sum::<usize>()
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Helper functions
383// ---------------------------------------------------------------------------
384
385/// Escape special HTML characters in `s`.
386fn html_escape(s: &str) -> String {
387    s.replace('&', "&amp;")
388        .replace('<', "&lt;")
389        .replace('>', "&gt;")
390        .replace('"', "&quot;")
391}
392
393/// Escape LaTeX special characters.
394fn latex_escape(s: &str) -> String {
395    let mut out = String::with_capacity(s.len() + 8);
396    for ch in s.chars() {
397        match ch {
398            '&' => out.push_str(r"\&"),
399            '%' => out.push_str(r"\%"),
400            '$' => out.push_str(r"\$"),
401            '#' => out.push_str(r"\#"),
402            '_' => out.push_str(r"\_"),
403            '{' => out.push_str(r"\{"),
404            '}' => out.push_str(r"\}"),
405            '~' => out.push_str(r"\textasciitilde{}"),
406            '^' => out.push_str(r"\textasciicircum{}"),
407            '\\' => out.push_str(r"\textbackslash{}"),
408            other => out.push(other),
409        }
410    }
411    out
412}
413
414/// Build a Markdown table string from a [`DataTable`].
415fn markdown_table(table: &DataTable) -> String {
416    let mut out = String::new();
417    // Caption
418    if !table.caption.is_empty() {
419        let _ = writeln!(out, "**{}**\n", table.caption);
420    }
421    if table.headers.is_empty() {
422        return out;
423    }
424    // Header row
425    let _ = write!(out, "|");
426    for h in &table.headers {
427        let _ = write!(out, " {} |", h);
428    }
429    out.push('\n');
430    // Separator
431    let _ = write!(out, "|");
432    for _ in &table.headers {
433        let _ = write!(out, " --- |");
434    }
435    out.push('\n');
436    // Data rows
437    for row in &table.rows {
438        let _ = write!(out, "|");
439        for cell in row {
440            let _ = write!(out, " {} |", cell.to_display());
441        }
442        out.push('\n');
443    }
444    out
445}
446
447/// Build an HTML `<table>` string from a [`DataTable`].
448fn html_table(table: &DataTable) -> String {
449    let mut out = String::new();
450    let _ = write!(out, "<table class=\"data-table\">");
451    if !table.caption.is_empty() {
452        let _ = write!(out, "<caption>{}</caption>", html_escape(&table.caption));
453    }
454    // thead
455    if !table.headers.is_empty() {
456        let _ = write!(out, "<thead><tr>");
457        for h in &table.headers {
458            let _ = write!(out, "<th>{}</th>", html_escape(h));
459        }
460        let _ = write!(out, "</tr></thead>");
461    }
462    // tbody
463    let _ = write!(out, "<tbody>");
464    for (i, row) in table.rows.iter().enumerate() {
465        let class = if let Some(lbl) = table.row_highlights.get(&i) {
466            format!(" class=\"{}\"", html_escape(lbl))
467        } else {
468            String::new()
469        };
470        let _ = write!(out, "<tr{}>", class);
471        for cell in row {
472            let _ = write!(out, "<td>{}</td>", cell.to_html());
473        }
474        let _ = write!(out, "</tr>");
475    }
476    let _ = write!(out, "</tbody></table>");
477    out
478}
479
480/// Build a LaTeX `tabular` environment from a [`DataTable`].
481fn latex_tabular(table: &DataTable) -> String {
482    let ncols = table.headers.len();
483    let col_spec: String = std::iter::repeat_n("l", ncols)
484        .collect::<Vec<_>>()
485        .join("|");
486    let mut out = String::new();
487    if !table.caption.is_empty() {
488        let _ = writeln!(out, "\\begin{{table}}[htbp]");
489        let _ = writeln!(out, "\\centering");
490        let _ = writeln!(out, "\\caption{{{}}}", latex_escape(&table.caption));
491    }
492    let _ = writeln!(out, "\\begin{{tabular}}{{|{}|}}", col_spec);
493    let _ = writeln!(out, "\\hline");
494    // Header
495    if !table.headers.is_empty() {
496        let header_row = table
497            .headers
498            .iter()
499            .map(|h| format!("\\textbf{{{}}}", latex_escape(h)))
500            .collect::<Vec<_>>()
501            .join(" & ");
502        let _ = writeln!(out, "{} \\\\", header_row);
503        let _ = writeln!(out, "\\hline");
504    }
505    // Data rows
506    for row in &table.rows {
507        let row_str = row
508            .iter()
509            .map(|c| c.to_latex())
510            .collect::<Vec<_>>()
511            .join(" & ");
512        let _ = writeln!(out, "{} \\\\", row_str);
513    }
514    let _ = writeln!(out, "\\hline");
515    let _ = writeln!(out, "\\end{{tabular}}");
516    if !table.caption.is_empty() {
517        let _ = writeln!(out, "\\end{{table}}");
518    }
519    out
520}
521
522// ---------------------------------------------------------------------------
523// HtmlReportWriter
524// ---------------------------------------------------------------------------
525
526/// Writes a [`SimulationReport`] as a self-contained HTML document with
527/// embedded CSS styling.
528///
529/// Usage:
530/// ```no_run
531/// use oxiphysics_io::simulation_report_io::{SimulationReport, HtmlReportWriter};
532/// let report = SimulationReport::new("My Simulation");
533/// let html = HtmlReportWriter::new().write(&report);
534/// assert!(html.contains("<html"));
535/// ```
536#[derive(Debug, Clone, Default)]
537pub struct HtmlReportWriter {
538    /// Whether to embed the default CSS stylesheet.
539    pub embed_css: bool,
540    /// Custom CSS to append after the default stylesheet.
541    pub extra_css: String,
542}
543
544impl HtmlReportWriter {
545    /// Create a new writer with embedded CSS enabled.
546    pub fn new() -> Self {
547        Self {
548            embed_css: true,
549            extra_css: String::new(),
550        }
551    }
552
553    /// Builder: disable embedded CSS.
554    pub fn without_css(mut self) -> Self {
555        self.embed_css = false;
556        self
557    }
558
559    /// Builder: append extra CSS rules.
560    pub fn with_extra_css(mut self, css: impl Into<String>) -> Self {
561        self.extra_css = css.into();
562        self
563    }
564
565    /// Render the report to an HTML string.
566    pub fn write(&self, report: &SimulationReport) -> String {
567        let mut out = String::new();
568        let _ = writeln!(out, "<!DOCTYPE html>");
569        let _ = writeln!(out, "<html lang=\"en\">");
570        let _ = writeln!(out, "<head>");
571        let _ = writeln!(out, "<meta charset=\"UTF-8\">");
572        let _ = writeln!(
573            out,
574            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
575        );
576        let _ = writeln!(out, "<title>{}</title>", html_escape(&report.title));
577        if self.embed_css {
578            let _ = writeln!(out, "<style>");
579            out.push_str(DEFAULT_CSS);
580            if !self.extra_css.is_empty() {
581                out.push_str(&self.extra_css);
582            }
583            let _ = writeln!(out, "</style>");
584        }
585        let _ = writeln!(out, "</head>");
586        let _ = writeln!(out, "<body>");
587        let _ = writeln!(out, "<div class=\"container\">");
588
589        // Title block
590        let _ = writeln!(
591            out,
592            "<h1 class=\"report-title\">{}</h1>",
593            html_escape(&report.title)
594        );
595        if !report.author.is_empty() || !report.date.is_empty() {
596            let _ = write!(out, "<p class=\"report-meta\">");
597            if !report.author.is_empty() {
598                let _ = write!(
599                    out,
600                    "<strong>Author:</strong> {} ",
601                    html_escape(&report.author)
602                );
603            }
604            if !report.date.is_empty() {
605                let _ = write!(out, "<strong>Date:</strong> {}", html_escape(&report.date));
606            }
607            if let Some(ref ver) = report.version {
608                let _ = write!(out, " <strong>Version:</strong> {}", html_escape(ver));
609            }
610            let _ = writeln!(out, "</p>");
611        }
612
613        // Abstract
614        if !report.description.is_empty() {
615            let _ = writeln!(out, "<div class=\"abstract\">");
616            let _ = writeln!(out, "<h2>Abstract</h2>");
617            let _ = writeln!(out, "<p>{}</p>", html_escape(&report.description));
618            let _ = writeln!(out, "</div>");
619        }
620
621        // Parameters table
622        if !report.parameters.is_empty() {
623            let _ = writeln!(out, "<h2>Parameters</h2>");
624            let _ = writeln!(out, "<table class=\"data-table\">");
625            let _ = writeln!(
626                out,
627                "<thead><tr><th>Parameter</th><th>Value</th></tr></thead>"
628            );
629            let _ = writeln!(out, "<tbody>");
630            let mut params: Vec<(&String, &String)> = report.parameters.iter().collect();
631            params.sort_by_key(|(k, _)| k.as_str());
632            for (k, v) in params {
633                let _ = writeln!(
634                    out,
635                    "<tr><td>{}</td><td>{}</td></tr>",
636                    html_escape(k),
637                    html_escape(v)
638                );
639            }
640            let _ = writeln!(out, "</tbody></table>");
641        }
642
643        // Results table
644        if !report.results.is_empty() {
645            let _ = writeln!(out, "<h2>Results</h2>");
646            let _ = writeln!(out, "<table class=\"data-table\">");
647            let _ = writeln!(out, "<thead><tr><th>Metric</th><th>Value</th></tr></thead>");
648            let _ = writeln!(out, "<tbody>");
649            let mut res: Vec<(&String, &String)> = report.results.iter().collect();
650            res.sort_by_key(|(k, _)| k.as_str());
651            for (k, v) in res {
652                let _ = writeln!(
653                    out,
654                    "<tr><td>{}</td><td>{}</td></tr>",
655                    html_escape(k),
656                    html_escape(v)
657                );
658            }
659            let _ = writeln!(out, "</tbody></table>");
660        }
661
662        // Top-level tables
663        for table in &report.tables {
664            out.push_str(&html_table(table));
665            out.push('\n');
666        }
667
668        // Top-level figures
669        for fig in &report.figures {
670            self.write_html_figure(&mut out, fig);
671        }
672
673        // Sections
674        for section in &report.sections {
675            self.write_html_section(&mut out, section);
676        }
677
678        let _ = writeln!(out, "</div>"); // container
679        let _ = writeln!(out, "</body></html>");
680        out
681    }
682
683    fn write_html_section(&self, out: &mut String, section: &ReportSection) {
684        let tag = if section.level == 0 { "h2" } else { "h3" };
685        let _ = writeln!(out, "<{tag}>{}</{tag}>", html_escape(&section.title));
686        if !section.text.is_empty() {
687            for para in section.text.split("\n\n") {
688                let _ = writeln!(out, "<p>{}</p>", html_escape(para.trim()));
689            }
690        }
691        for table in &section.tables {
692            out.push_str(&html_table(table));
693            out.push('\n');
694        }
695        for fig in &section.figures {
696            self.write_html_figure(out, fig);
697        }
698    }
699
700    fn write_html_figure(&self, out: &mut String, fig: &FigureRef) {
701        let _ = writeln!(out, "<figure id=\"{}\">", html_escape(&fig.label));
702        let alt = fig.alt.as_deref().unwrap_or(&fig.caption);
703        let width_attr = if let Some(ref w) = fig.width {
704            format!(" width=\"{}\"", html_escape(w))
705        } else {
706            String::new()
707        };
708        let _ = writeln!(
709            out,
710            "<img src=\"{}\" alt=\"{}\"{} />",
711            html_escape(&fig.path),
712            html_escape(alt),
713            width_attr
714        );
715        let _ = writeln!(
716            out,
717            "<figcaption>{}</figcaption>",
718            html_escape(&fig.caption)
719        );
720        let _ = writeln!(out, "</figure>");
721    }
722}
723
724/// Default CSS embedded in HTML reports.
725const DEFAULT_CSS: &str = r#"
726body {
727    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
728    line-height: 1.6;
729    color: #333;
730    background: #fafafa;
731    margin: 0;
732    padding: 0;
733}
734.container {
735    max-width: 960px;
736    margin: 2rem auto;
737    background: #fff;
738    padding: 2rem 3rem;
739    box-shadow: 0 2px 8px rgba(0,0,0,.12);
740    border-radius: 6px;
741}
742.report-title {
743    font-size: 2rem;
744    border-bottom: 2px solid #3b82f6;
745    padding-bottom: .4rem;
746    margin-bottom: .5rem;
747}
748.report-meta {
749    color: #555;
750    font-size: .9rem;
751    margin-bottom: 1.5rem;
752}
753.abstract {
754    background: #eff6ff;
755    border-left: 4px solid #3b82f6;
756    padding: .8rem 1rem;
757    margin-bottom: 1.5rem;
758    border-radius: 0 4px 4px 0;
759}
760h2 { font-size: 1.4rem; margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: .3rem; }
761h3 { font-size: 1.15rem; margin-top: 1.5rem; }
762table.data-table {
763    border-collapse: collapse;
764    width: 100%;
765    margin: 1rem 0;
766    font-size: .9rem;
767}
768table.data-table th, table.data-table td {
769    border: 1px solid #d1d5db;
770    padding: .5rem .75rem;
771    text-align: left;
772}
773table.data-table thead tr { background: #3b82f6; color: #fff; }
774table.data-table tbody tr:nth-child(even) { background: #f3f4f6; }
775table.data-table caption { caption-side: top; font-weight: bold; margin-bottom: .4rem; }
776table.data-table tr.highlight { background: #fef9c3; }
777figure { margin: 1.5rem 0; text-align: center; }
778figure img { max-width: 100%; border-radius: 4px; }
779figcaption { color: #555; font-size: .85rem; margin-top: .4rem; font-style: italic; }
780"#;
781
782// ---------------------------------------------------------------------------
783// MarkdownReportWriter
784// ---------------------------------------------------------------------------
785
786/// Writes a [`SimulationReport`] as GitHub-flavored Markdown.
787///
788/// Usage:
789/// ```no_run
790/// use oxiphysics_io::simulation_report_io::{SimulationReport, MarkdownReportWriter};
791/// let report = SimulationReport::new("My Simulation");
792/// let md = MarkdownReportWriter::new().write(&report);
793/// assert!(md.contains("# My Simulation"));
794/// ```
795#[derive(Debug, Clone, Default)]
796pub struct MarkdownReportWriter {
797    /// Whether to add a horizontal rule between sections.
798    pub section_dividers: bool,
799}
800
801impl MarkdownReportWriter {
802    /// Create a new writer with section dividers enabled.
803    pub fn new() -> Self {
804        Self {
805            section_dividers: true,
806        }
807    }
808
809    /// Render the report to a Markdown string.
810    pub fn write(&self, report: &SimulationReport) -> String {
811        let mut out = String::new();
812
813        // Title
814        let _ = writeln!(out, "# {}", report.title);
815        out.push('\n');
816
817        // Meta
818        if !report.author.is_empty() {
819            let _ = writeln!(out, "**Author:** {}  ", report.author);
820        }
821        if !report.date.is_empty() {
822            let _ = writeln!(out, "**Date:** {}  ", report.date);
823        }
824        if let Some(ref ver) = report.version {
825            let _ = writeln!(out, "**Version:** {}  ", ver);
826        }
827        if !report.author.is_empty() || !report.date.is_empty() {
828            out.push('\n');
829        }
830
831        // Abstract
832        if !report.description.is_empty() {
833            let _ = writeln!(out, "## Abstract");
834            out.push('\n');
835            let _ = writeln!(out, "{}", report.description);
836            out.push('\n');
837        }
838
839        // Parameters
840        if !report.parameters.is_empty() {
841            let _ = writeln!(out, "## Parameters");
842            out.push('\n');
843            let _ = writeln!(out, "| Parameter | Value |");
844            let _ = writeln!(out, "| --- | --- |");
845            let mut params: Vec<(&String, &String)> = report.parameters.iter().collect();
846            params.sort_by_key(|(k, _)| k.as_str());
847            for (k, v) in params {
848                let _ = writeln!(out, "| {} | {} |", k, v);
849            }
850            out.push('\n');
851        }
852
853        // Results
854        if !report.results.is_empty() {
855            let _ = writeln!(out, "## Results");
856            out.push('\n');
857            let _ = writeln!(out, "| Metric | Value |");
858            let _ = writeln!(out, "| --- | --- |");
859            let mut res: Vec<(&String, &String)> = report.results.iter().collect();
860            res.sort_by_key(|(k, _)| k.as_str());
861            for (k, v) in res {
862                let _ = writeln!(out, "| {} | {} |", k, v);
863            }
864            out.push('\n');
865        }
866
867        // Top-level tables
868        for table in &report.tables {
869            out.push_str(&markdown_table(table));
870            out.push('\n');
871        }
872
873        // Top-level figures
874        for fig in &report.figures {
875            let alt = fig.alt.as_deref().unwrap_or(&fig.caption);
876            let _ = writeln!(out, "![{}]({})", alt, fig.path);
877            let _ = writeln!(out, "*Figure: {}*", fig.caption);
878            out.push('\n');
879        }
880
881        // Sections
882        for section in &report.sections {
883            if self.section_dividers {
884                let _ = writeln!(out, "---");
885                out.push('\n');
886            }
887            self.write_md_section(&mut out, section);
888        }
889
890        out
891    }
892
893    fn write_md_section(&self, out: &mut String, section: &ReportSection) {
894        let prefix = if section.level == 0 { "##" } else { "###" };
895        let _ = writeln!(out, "{} {}", prefix, section.title);
896        out.push('\n');
897
898        if !section.text.is_empty() {
899            let _ = writeln!(out, "{}", section.text);
900            out.push('\n');
901        }
902
903        for table in &section.tables {
904            out.push_str(&markdown_table(table));
905            out.push('\n');
906        }
907
908        for fig in &section.figures {
909            let alt = fig.alt.as_deref().unwrap_or(&fig.caption);
910            let _ = writeln!(out, "![{}]({})", alt, fig.path);
911            let _ = writeln!(out, "*Figure: {}*", fig.caption);
912            out.push('\n');
913        }
914    }
915}
916
917// ---------------------------------------------------------------------------
918// LatexReportWriter
919// ---------------------------------------------------------------------------
920
921/// Writes a [`SimulationReport`] as a compilable LaTeX `article` document.
922///
923/// Usage:
924/// ```no_run
925/// use oxiphysics_io::simulation_report_io::{SimulationReport, LatexReportWriter};
926/// let report = SimulationReport::new("My Simulation");
927/// let tex = LatexReportWriter::new().write(&report);
928/// assert!(tex.contains("\\documentclass"));
929/// ```
930#[derive(Debug, Clone)]
931pub struct LatexReportWriter {
932    /// Paper size option for `geometry` package (e.g. `"a4paper"`).
933    pub paper: String,
934    /// Font size (e.g. `"11pt"`).
935    pub font_size: String,
936    /// Whether to include `\usepackage{booktabs}` and use `\toprule` etc.
937    pub use_booktabs: bool,
938    /// Whether to include the `graphicx` package.
939    pub use_graphicx: bool,
940}
941
942impl Default for LatexReportWriter {
943    fn default() -> Self {
944        Self {
945            paper: "a4paper".to_string(),
946            font_size: "11pt".to_string(),
947            use_booktabs: true,
948            use_graphicx: true,
949        }
950    }
951}
952
953impl LatexReportWriter {
954    /// Create a new writer with sensible defaults.
955    pub fn new() -> Self {
956        Self::default()
957    }
958
959    /// Render the report to a LaTeX string.
960    pub fn write(&self, report: &SimulationReport) -> String {
961        let mut out = String::new();
962
963        // Preamble
964        let _ = writeln!(
965            out,
966            "\\documentclass[{},{}]{{article}}",
967            self.font_size, self.paper
968        );
969        let _ = writeln!(out, "\\usepackage[utf8]{{inputenc}}");
970        let _ = writeln!(out, "\\usepackage[T1]{{fontenc}}");
971        let _ = writeln!(out, "\\usepackage{{geometry}}");
972        let _ = writeln!(out, "\\geometry{{margin=2.5cm}}");
973        let _ = writeln!(out, "\\usepackage{{hyperref}}");
974        if self.use_booktabs {
975            let _ = writeln!(out, "\\usepackage{{booktabs}}");
976        }
977        if self.use_graphicx {
978            let _ = writeln!(out, "\\usepackage{{graphicx}}");
979        }
980        let _ = writeln!(out, "\\usepackage{{array}}");
981        let _ = writeln!(out, "\\usepackage{{colortbl}}");
982        let _ = writeln!(out, "\\usepackage{{xcolor}}");
983        out.push('\n');
984
985        // Title, author, date
986        let _ = writeln!(out, "\\title{{{}}}", latex_escape(&report.title));
987        if !report.author.is_empty() {
988            let _ = writeln!(out, "\\author{{{}}}", latex_escape(&report.author));
989        }
990        if !report.date.is_empty() {
991            let _ = writeln!(out, "\\date{{{}}}", latex_escape(&report.date));
992        } else {
993            let _ = writeln!(out, "\\date{{\\today}}");
994        }
995        out.push('\n');
996
997        // Begin document
998        let _ = writeln!(out, "\\begin{{document}}");
999        let _ = writeln!(out, "\\maketitle");
1000        if let Some(ref ver) = report.version {
1001            let _ = writeln!(
1002                out,
1003                "\\begin{{center}}\\small Version: {}\\end{{center}}",
1004                latex_escape(ver)
1005            );
1006        }
1007        out.push('\n');
1008
1009        // Abstract
1010        if !report.description.is_empty() {
1011            let _ = writeln!(out, "\\begin{{abstract}}");
1012            let _ = writeln!(out, "{}", latex_escape(&report.description));
1013            let _ = writeln!(out, "\\end{{abstract}}");
1014            out.push('\n');
1015        }
1016
1017        // Table of contents (optional, include for longer reports)
1018        let _ = writeln!(out, "\\tableofcontents");
1019        let _ = writeln!(out, "\\newpage");
1020        out.push('\n');
1021
1022        // Parameters
1023        if !report.parameters.is_empty() {
1024            let _ = writeln!(out, "\\section{{Parameters}}");
1025            let mut params: Vec<(&String, &String)> = report.parameters.iter().collect();
1026            params.sort_by_key(|(k, _)| k.as_str());
1027            let _ = writeln!(out, "\\begin{{table}}[htbp]\\centering");
1028            let _ = writeln!(out, "\\caption{{Simulation Parameters}}");
1029            let _ = writeln!(out, "\\begin{{tabular}}{{|l|l|}}\\hline");
1030            let _ = writeln!(out, "\\textbf{{Parameter}} & \\textbf{{Value}} \\\\\\hline");
1031            for (k, v) in params {
1032                let _ = writeln!(out, "{} & {} \\\\", latex_escape(k), latex_escape(v));
1033            }
1034            let _ = writeln!(out, "\\hline\\end{{tabular}}\\end{{table}}");
1035            out.push('\n');
1036        }
1037
1038        // Results
1039        if !report.results.is_empty() {
1040            let _ = writeln!(out, "\\section{{Results Summary}}");
1041            let mut res: Vec<(&String, &String)> = report.results.iter().collect();
1042            res.sort_by_key(|(k, _)| k.as_str());
1043            let _ = writeln!(out, "\\begin{{table}}[htbp]\\centering");
1044            let _ = writeln!(out, "\\caption{{Result Metrics}}");
1045            let _ = writeln!(out, "\\begin{{tabular}}{{|l|l|}}\\hline");
1046            let _ = writeln!(out, "\\textbf{{Metric}} & \\textbf{{Value}} \\\\\\hline");
1047            for (k, v) in res {
1048                let _ = writeln!(out, "{} & {} \\\\", latex_escape(k), latex_escape(v));
1049            }
1050            let _ = writeln!(out, "\\hline\\end{{tabular}}\\end{{table}}");
1051            out.push('\n');
1052        }
1053
1054        // Top-level tables
1055        for table in &report.tables {
1056            out.push_str(&latex_tabular(table));
1057            out.push('\n');
1058        }
1059
1060        // Top-level figures
1061        for fig in &report.figures {
1062            self.write_latex_figure(&mut out, fig);
1063        }
1064
1065        // Sections
1066        for section in &report.sections {
1067            self.write_latex_section(&mut out, section);
1068        }
1069
1070        let _ = writeln!(out, "\\end{{document}}");
1071        out
1072    }
1073
1074    fn write_latex_section(&self, out: &mut String, section: &ReportSection) {
1075        let cmd = if section.level == 0 {
1076            "\\section"
1077        } else {
1078            "\\subsection"
1079        };
1080        let _ = writeln!(out, "{}{{{}}}", cmd, latex_escape(&section.title));
1081        out.push('\n');
1082
1083        if !section.text.is_empty() {
1084            for para in section.text.split("\n\n") {
1085                let _ = writeln!(out, "{}\n", latex_escape(para.trim()));
1086            }
1087        }
1088
1089        for table in &section.tables {
1090            out.push_str(&latex_tabular(table));
1091            out.push('\n');
1092        }
1093
1094        for fig in &section.figures {
1095            self.write_latex_figure(out, fig);
1096        }
1097    }
1098
1099    fn write_latex_figure(&self, out: &mut String, fig: &FigureRef) {
1100        let width = fig.width.as_deref().unwrap_or("0.8\\linewidth");
1101        let _ = writeln!(out, "\\begin{{figure}}[htbp]");
1102        let _ = writeln!(out, "\\centering");
1103        let _ = writeln!(
1104            out,
1105            "\\includegraphics[width={}]{{{}}}",
1106            width,
1107            latex_escape(&fig.path)
1108        );
1109        let _ = writeln!(out, "\\caption{{{}}}", latex_escape(&fig.caption));
1110        let _ = writeln!(out, "\\label{{fig:{}}}", latex_escape(&fig.label));
1111        let _ = writeln!(out, "\\end{{figure}}");
1112    }
1113}
1114
1115// ---------------------------------------------------------------------------
1116// ReportBuilder — convenience builder
1117// ---------------------------------------------------------------------------
1118
1119/// Fluent builder for constructing a [`SimulationReport`] and immediately
1120/// rendering it to a target format.
1121#[derive(Debug, Default)]
1122pub struct ReportBuilder {
1123    report: SimulationReport,
1124}
1125
1126impl ReportBuilder {
1127    /// Create a new builder.
1128    pub fn new(title: impl Into<String>) -> Self {
1129        Self {
1130            report: SimulationReport::new(title),
1131        }
1132    }
1133
1134    /// Set the author.
1135    pub fn author(mut self, author: impl Into<String>) -> Self {
1136        self.report.author = author.into();
1137        self
1138    }
1139
1140    /// Set the date.
1141    pub fn date(mut self, date: impl Into<String>) -> Self {
1142        self.report.date = date.into();
1143        self
1144    }
1145
1146    /// Set the description.
1147    pub fn description(mut self, desc: impl Into<String>) -> Self {
1148        self.report.description = desc.into();
1149        self
1150    }
1151
1152    /// Add a parameter.
1153    pub fn parameter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1154        self.report.add_parameter(key, value);
1155        self
1156    }
1157
1158    /// Add a result metric.
1159    pub fn result(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1160        self.report.add_result(key, value);
1161        self
1162    }
1163
1164    /// Add a section.
1165    pub fn section(mut self, section: ReportSection) -> Self {
1166        self.report.add_section(section);
1167        self
1168    }
1169
1170    /// Finalize and return the [`SimulationReport`].
1171    pub fn build(self) -> SimulationReport {
1172        self.report
1173    }
1174
1175    /// Finalize and render as HTML.
1176    pub fn to_html(self) -> String {
1177        HtmlReportWriter::new().write(&self.report)
1178    }
1179
1180    /// Finalize and render as Markdown.
1181    pub fn to_markdown(self) -> String {
1182        MarkdownReportWriter::new().write(&self.report)
1183    }
1184
1185    /// Finalize and render as LaTeX.
1186    pub fn to_latex(self) -> String {
1187        LatexReportWriter::new().write(&self.report)
1188    }
1189}
1190
1191// ---------------------------------------------------------------------------
1192// Tests
1193// ---------------------------------------------------------------------------
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198
1199    // ── CellValue ─────────────────────────────────────────────────────────────
1200
1201    #[test]
1202    fn test_cell_int_display() {
1203        assert_eq!(CellValue::Int(42).to_display(), "42");
1204    }
1205
1206    #[test]
1207    fn test_cell_float_default_precision() {
1208        let s = CellValue::Float(2.54321, None).to_display();
1209        assert!(s.starts_with("2.54321"), "got {s}");
1210    }
1211
1212    #[test]
1213    fn test_cell_float_custom_precision() {
1214        let s = CellValue::Float(2.54321, Some(2)).to_display();
1215        assert_eq!(s, "2.54");
1216    }
1217
1218    #[test]
1219    fn test_cell_text_display() {
1220        assert_eq!(CellValue::Text("hello".into()).to_display(), "hello");
1221    }
1222
1223    #[test]
1224    fn test_cell_bool_true() {
1225        assert_eq!(CellValue::Bool(true).to_display(), "true");
1226    }
1227
1228    #[test]
1229    fn test_cell_bool_false() {
1230        assert_eq!(CellValue::Bool(false).to_display(), "false");
1231    }
1232
1233    #[test]
1234    fn test_cell_empty_display() {
1235        assert_eq!(CellValue::Empty.to_display(), "");
1236    }
1237
1238    #[test]
1239    fn test_cell_html_escape() {
1240        let s = CellValue::Text("<b>bold & italic</b>".into()).to_html();
1241        assert!(s.contains("&lt;"), "got {s}");
1242        assert!(s.contains("&amp;"), "got {s}");
1243    }
1244
1245    #[test]
1246    fn test_cell_latex_escape() {
1247        let s = CellValue::Text("x_1 & y_2".into()).to_latex();
1248        assert!(s.contains(r"\_"), "got {s}");
1249        assert!(s.contains(r"\&"), "got {s}");
1250    }
1251
1252    // ── DataTable ──────────────────────────────────────────────────────────────
1253
1254    #[test]
1255    fn test_data_table_add_row() {
1256        let mut t = DataTable::new("T", vec!["A".into(), "B".into()]);
1257        t.add_row(vec![CellValue::Int(1), CellValue::Int(2)]);
1258        assert_eq!(t.row_count(), 1);
1259    }
1260
1261    #[test]
1262    fn test_data_table_col_count() {
1263        let t = DataTable::new("T", vec!["A".into(), "B".into(), "C".into()]);
1264        assert_eq!(t.col_count(), 3);
1265    }
1266
1267    #[test]
1268    fn test_data_table_is_empty() {
1269        let t = DataTable::new("T", vec!["A".into()]);
1270        assert!(t.is_empty());
1271    }
1272
1273    #[test]
1274    fn test_data_table_highlight_row() {
1275        let mut t = DataTable::new("T", vec!["A".into()]);
1276        t.add_row(vec![CellValue::Int(1)]);
1277        t.highlight_row(0, "highlight");
1278        assert_eq!(t.row_highlights.get(&0).unwrap(), "highlight");
1279    }
1280
1281    #[test]
1282    fn test_data_table_column_widths() {
1283        let mut t = DataTable::new("T", vec!["Name".into(), "Val".into()]);
1284        t.add_row(vec![CellValue::Text("Alice".into()), CellValue::Int(100)]);
1285        let widths = t.column_widths();
1286        assert_eq!(widths[0], 5); // "Alice"
1287        assert!(widths[1] >= 3); // "Val" or "100"
1288    }
1289
1290    #[test]
1291    fn test_data_table_set_column_type() {
1292        let mut t = DataTable::new("T", vec!["A".into(), "B".into()]);
1293        t.set_column_type(0, ColumnType::Integer);
1294        assert_eq!(t.column_types[0], ColumnType::Integer);
1295    }
1296
1297    // ── FigureRef ─────────────────────────────────────────────────────────────
1298
1299    #[test]
1300    fn test_figure_ref_new() {
1301        let f = FigureRef::new("fig1", "plot.png", "My Plot");
1302        assert_eq!(f.label, "fig1");
1303        assert_eq!(f.path, "plot.png");
1304        assert_eq!(f.caption, "My Plot");
1305        assert!(f.alt.is_none());
1306    }
1307
1308    #[test]
1309    fn test_figure_ref_with_alt_and_width() {
1310        let f = FigureRef::new("f", "a.png", "cap")
1311            .with_alt("alt text")
1312            .with_width("80%");
1313        assert_eq!(f.alt.as_deref(), Some("alt text"));
1314        assert_eq!(f.width.as_deref(), Some("80%"));
1315    }
1316
1317    // ── ReportSection ─────────────────────────────────────────────────────────
1318
1319    #[test]
1320    fn test_report_section_new() {
1321        let s = ReportSection::new("Intro");
1322        assert_eq!(s.title, "Intro");
1323        assert_eq!(s.level, 0);
1324        assert!(s.tables.is_empty());
1325    }
1326
1327    #[test]
1328    fn test_report_section_subsection_level() {
1329        let s = ReportSection::subsection("Sub");
1330        assert_eq!(s.level, 1);
1331    }
1332
1333    #[test]
1334    fn test_report_section_append_text() {
1335        let mut s = ReportSection::new("S");
1336        s.append_text("Para 1.");
1337        s.append_text("Para 2.");
1338        assert!(s.text.contains("Para 1."));
1339        assert!(s.text.contains("Para 2."));
1340        assert!(s.text.contains("\n\n"));
1341    }
1342
1343    #[test]
1344    fn test_report_section_add_table() {
1345        let mut s = ReportSection::new("S");
1346        s.add_table(DataTable::new("T", vec!["X".into()]));
1347        assert_eq!(s.tables.len(), 1);
1348    }
1349
1350    #[test]
1351    fn test_report_section_add_figure() {
1352        let mut s = ReportSection::new("S");
1353        s.add_figure(FigureRef::new("f", "a.png", "cap"));
1354        assert_eq!(s.figures.len(), 1);
1355    }
1356
1357    // ── SimulationReport ──────────────────────────────────────────────────────
1358
1359    #[test]
1360    fn test_report_new() {
1361        let r = SimulationReport::new("Test Report");
1362        assert_eq!(r.title, "Test Report");
1363        assert!(r.parameters.is_empty());
1364        assert!(r.results.is_empty());
1365    }
1366
1367    #[test]
1368    fn test_report_builder_chain() {
1369        let r = SimulationReport::new("T")
1370            .with_description("desc")
1371            .with_author("Alice")
1372            .with_date("2026-01-01")
1373            .with_version("1.0.0");
1374        assert_eq!(r.description, "desc");
1375        assert_eq!(r.author, "Alice");
1376        assert_eq!(r.version.as_deref(), Some("1.0.0"));
1377    }
1378
1379    #[test]
1380    fn test_report_add_parameter() {
1381        let mut r = SimulationReport::new("T");
1382        r.add_parameter("dt", "0.001");
1383        assert_eq!(r.parameters.get("dt").map(|s| s.as_str()), Some("0.001"));
1384    }
1385
1386    #[test]
1387    fn test_report_add_result() {
1388        let mut r = SimulationReport::new("T");
1389        r.add_result("energy", "42.5");
1390        assert_eq!(r.results.get("energy").map(|s| s.as_str()), Some("42.5"));
1391    }
1392
1393    #[test]
1394    fn test_report_total_tables() {
1395        let mut r = SimulationReport::new("T");
1396        r.add_table(DataTable::new("T1", vec![]));
1397        let mut sec = ReportSection::new("S");
1398        sec.add_table(DataTable::new("T2", vec![]));
1399        r.add_section(sec);
1400        assert_eq!(r.total_tables(), 2);
1401    }
1402
1403    #[test]
1404    fn test_report_total_figures() {
1405        let mut r = SimulationReport::new("T");
1406        r.add_figure(FigureRef::new("f1", "a.png", "A"));
1407        let mut sec = ReportSection::new("S");
1408        sec.add_figure(FigureRef::new("f2", "b.png", "B"));
1409        r.add_section(sec);
1410        assert_eq!(r.total_figures(), 2);
1411    }
1412
1413    // ── HtmlReportWriter ──────────────────────────────────────────────────────
1414
1415    #[test]
1416    fn test_html_writer_contains_doctype() {
1417        let r = SimulationReport::new("Test");
1418        let html = HtmlReportWriter::new().write(&r);
1419        assert!(html.contains("<!DOCTYPE html>"), "no doctype in:\n{html}");
1420    }
1421
1422    #[test]
1423    fn test_html_writer_contains_title() {
1424        let r = SimulationReport::new("My Report");
1425        let html = HtmlReportWriter::new().write(&r);
1426        assert!(html.contains("My Report"), "title missing");
1427    }
1428
1429    #[test]
1430    fn test_html_writer_contains_css() {
1431        let r = SimulationReport::new("T");
1432        let html = HtmlReportWriter::new().write(&r);
1433        assert!(html.contains("<style>"), "CSS missing");
1434    }
1435
1436    #[test]
1437    fn test_html_writer_no_css_when_disabled() {
1438        let r = SimulationReport::new("T");
1439        let html = HtmlReportWriter::new().without_css().write(&r);
1440        assert!(!html.contains("<style>"), "CSS should be absent");
1441    }
1442
1443    #[test]
1444    fn test_html_writer_parameters_table() {
1445        let mut r = SimulationReport::new("T");
1446        r.add_parameter("alpha", "0.5");
1447        let html = HtmlReportWriter::new().write(&r);
1448        assert!(html.contains("alpha"), "param key missing");
1449        assert!(html.contains("0.5"), "param value missing");
1450    }
1451
1452    #[test]
1453    fn test_html_writer_results_table() {
1454        let mut r = SimulationReport::new("T");
1455        r.add_result("max_stress", "1.23e9");
1456        let html = HtmlReportWriter::new().write(&r);
1457        assert!(html.contains("max_stress"));
1458        assert!(html.contains("1.23e9"));
1459    }
1460
1461    #[test]
1462    fn test_html_writer_section_heading() {
1463        let mut r = SimulationReport::new("T");
1464        r.add_section(ReportSection::new("Methodology"));
1465        let html = HtmlReportWriter::new().write(&r);
1466        assert!(html.contains("Methodology"));
1467    }
1468
1469    #[test]
1470    fn test_html_writer_figure() {
1471        let mut r = SimulationReport::new("T");
1472        r.add_figure(FigureRef::new("f1", "energy.png", "Energy over time"));
1473        let html = HtmlReportWriter::new().write(&r);
1474        assert!(html.contains("energy.png"));
1475        assert!(html.contains("Energy over time"));
1476    }
1477
1478    #[test]
1479    fn test_html_writer_data_table() {
1480        let mut r = SimulationReport::new("T");
1481        let mut tbl = DataTable::new("Results", vec!["Step".into(), "E".into()]);
1482        tbl.add_row(vec![CellValue::Int(1), CellValue::Float(1.5, Some(2))]);
1483        r.add_table(tbl);
1484        let html = HtmlReportWriter::new().write(&r);
1485        assert!(html.contains("<table"), "table tag missing");
1486        assert!(html.contains("Step"));
1487        assert!(html.contains("1.50"));
1488    }
1489
1490    #[test]
1491    fn test_html_writer_html_escape_in_title() {
1492        let r = SimulationReport::new("Report <1> & <2>");
1493        let html = HtmlReportWriter::new().write(&r);
1494        assert!(html.contains("&lt;1&gt;"), "angle brackets not escaped");
1495        assert!(html.contains("&amp;"), "ampersand not escaped");
1496    }
1497
1498    // ── MarkdownReportWriter ──────────────────────────────────────────────────
1499
1500    #[test]
1501    fn test_md_writer_title_heading() {
1502        let r = SimulationReport::new("My Sim");
1503        let md = MarkdownReportWriter::new().write(&r);
1504        assert!(md.starts_with("# My Sim"), "title heading missing:\n{md}");
1505    }
1506
1507    #[test]
1508    fn test_md_writer_parameters_table() {
1509        let mut r = SimulationReport::new("T");
1510        r.add_parameter("dt", "0.01");
1511        let md = MarkdownReportWriter::new().write(&r);
1512        assert!(md.contains("## Parameters"), "header missing");
1513        assert!(md.contains("| dt |"), "param row missing");
1514    }
1515
1516    #[test]
1517    fn test_md_writer_results_table() {
1518        let mut r = SimulationReport::new("T");
1519        r.add_result("energy", "5.0");
1520        let md = MarkdownReportWriter::new().write(&r);
1521        assert!(md.contains("## Results"));
1522        assert!(md.contains("| energy |"));
1523    }
1524
1525    #[test]
1526    fn test_md_writer_section_heading() {
1527        let mut r = SimulationReport::new("T");
1528        r.add_section(ReportSection::new("Discussion"));
1529        let md = MarkdownReportWriter::new().write(&r);
1530        assert!(md.contains("## Discussion"));
1531    }
1532
1533    #[test]
1534    fn test_md_writer_subsection_heading() {
1535        let mut r = SimulationReport::new("T");
1536        r.add_section(ReportSection::subsection("Sub Details"));
1537        let md = MarkdownReportWriter::new().write(&r);
1538        assert!(md.contains("### Sub Details"));
1539    }
1540
1541    #[test]
1542    fn test_md_writer_dividers() {
1543        let mut r = SimulationReport::new("T");
1544        r.add_section(ReportSection::new("A"));
1545        r.add_section(ReportSection::new("B"));
1546        let md = MarkdownReportWriter::new().write(&r);
1547        assert!(md.contains("---"), "divider missing");
1548    }
1549
1550    #[test]
1551    fn test_md_writer_no_dividers_when_disabled() {
1552        let mut r = SimulationReport::new("T");
1553        r.add_section(ReportSection::new("A"));
1554        let w = MarkdownReportWriter {
1555            section_dividers: false,
1556        };
1557        let md = w.write(&r);
1558        assert!(!md.contains("\n---\n"), "divider should be absent");
1559    }
1560
1561    #[test]
1562    fn test_md_writer_figure() {
1563        let mut r = SimulationReport::new("T");
1564        r.add_figure(FigureRef::new("f", "plot.png", "Energy").with_alt("alt"));
1565        let md = MarkdownReportWriter::new().write(&r);
1566        assert!(md.contains("![alt](plot.png)"));
1567    }
1568
1569    #[test]
1570    fn test_md_writer_data_table() {
1571        let mut r = SimulationReport::new("T");
1572        let mut tbl = DataTable::new("T1", vec!["X".into(), "Y".into()]);
1573        tbl.add_row(vec![
1574            CellValue::Float(1.0, Some(1)),
1575            CellValue::Float(2.0, Some(1)),
1576        ]);
1577        r.add_table(tbl);
1578        let md = MarkdownReportWriter::new().write(&r);
1579        assert!(md.contains("| X |"), "header missing");
1580        assert!(md.contains("| 1.0 |"), "data missing");
1581    }
1582
1583    #[test]
1584    fn test_md_writer_author_and_date() {
1585        let r = SimulationReport::new("T")
1586            .with_author("Bob")
1587            .with_date("2026-03-24");
1588        let md = MarkdownReportWriter::new().write(&r);
1589        assert!(md.contains("**Author:** Bob"));
1590        assert!(md.contains("**Date:** 2026-03-24"));
1591    }
1592
1593    // ── LatexReportWriter ─────────────────────────────────────────────────────
1594
1595    #[test]
1596    fn test_latex_writer_contains_documentclass() {
1597        let r = SimulationReport::new("T");
1598        let tex = LatexReportWriter::new().write(&r);
1599        assert!(tex.contains("\\documentclass"), "no documentclass");
1600    }
1601
1602    #[test]
1603    fn test_latex_writer_begin_document() {
1604        let r = SimulationReport::new("T");
1605        let tex = LatexReportWriter::new().write(&r);
1606        assert!(tex.contains("\\begin{document}"));
1607        assert!(tex.contains("\\end{document}"));
1608    }
1609
1610    #[test]
1611    fn test_latex_writer_title() {
1612        let r = SimulationReport::new("Simulation Report");
1613        let tex = LatexReportWriter::new().write(&r);
1614        assert!(tex.contains("\\title{Simulation Report}"));
1615    }
1616
1617    #[test]
1618    fn test_latex_writer_parameters_section() {
1619        let mut r = SimulationReport::new("T");
1620        r.add_parameter("dt", "0.001");
1621        let tex = LatexReportWriter::new().write(&r);
1622        assert!(tex.contains("\\section{Parameters}"));
1623        assert!(tex.contains("dt"));
1624    }
1625
1626    #[test]
1627    fn test_latex_writer_results_section() {
1628        let mut r = SimulationReport::new("T");
1629        r.add_result("E_max", "42.0");
1630        let tex = LatexReportWriter::new().write(&r);
1631        assert!(tex.contains("\\section{Results Summary}"));
1632        assert!(tex.contains("E"), "result key missing");
1633    }
1634
1635    #[test]
1636    fn test_latex_writer_section() {
1637        let mut r = SimulationReport::new("T");
1638        r.add_section(ReportSection::new("Methodology"));
1639        let tex = LatexReportWriter::new().write(&r);
1640        assert!(tex.contains("\\section{Methodology}"));
1641    }
1642
1643    #[test]
1644    fn test_latex_writer_subsection() {
1645        let mut r = SimulationReport::new("T");
1646        r.add_section(ReportSection::subsection("Numerical Setup"));
1647        let tex = LatexReportWriter::new().write(&r);
1648        assert!(tex.contains("\\subsection{Numerical Setup}"));
1649    }
1650
1651    #[test]
1652    fn test_latex_writer_figure() {
1653        let mut r = SimulationReport::new("T");
1654        r.add_figure(FigureRef::new("fig1", "energy.pdf", "Energy Plot"));
1655        let tex = LatexReportWriter::new().write(&r);
1656        assert!(tex.contains("\\begin{figure}"));
1657        assert!(tex.contains("energy.pdf"));
1658        assert!(tex.contains("Energy Plot"));
1659    }
1660
1661    #[test]
1662    fn test_latex_writer_data_table() {
1663        let mut r = SimulationReport::new("T");
1664        let mut tbl = DataTable::new("Results", vec!["A".into(), "B".into()]);
1665        tbl.add_row(vec![CellValue::Int(1), CellValue::Float(2.5, Some(1))]);
1666        r.add_table(tbl);
1667        let tex = LatexReportWriter::new().write(&r);
1668        assert!(tex.contains("\\begin{tabular}"), "tabular missing");
1669        assert!(tex.contains("2.5"));
1670    }
1671
1672    #[test]
1673    fn test_latex_escape_special_chars() {
1674        assert_eq!(latex_escape("x_1 & y"), r"x\_1 \& y");
1675        assert_eq!(latex_escape("100%"), r"100\%");
1676        assert_eq!(latex_escape("$10"), r"\$10");
1677    }
1678
1679    #[test]
1680    fn test_latex_writer_version() {
1681        let r = SimulationReport::new("T").with_version("2.0");
1682        let tex = LatexReportWriter::new().write(&r);
1683        assert!(tex.contains("Version: 2.0"));
1684    }
1685
1686    // ── ReportBuilder ─────────────────────────────────────────────────────────
1687
1688    #[test]
1689    fn test_report_builder_to_html() {
1690        let html = ReportBuilder::new("Builder Test")
1691            .author("Alice")
1692            .description("A test report")
1693            .parameter("n", "100")
1694            .result("max_err", "1e-5")
1695            .to_html();
1696        assert!(html.contains("Builder Test"));
1697        assert!(html.contains("Alice"));
1698        assert!(html.contains("max_err"));
1699    }
1700
1701    #[test]
1702    fn test_report_builder_to_markdown() {
1703        let md = ReportBuilder::new("MD Report")
1704            .description("Desc")
1705            .parameter("dt", "0.01")
1706            .to_markdown();
1707        assert!(md.starts_with("# MD Report"));
1708        assert!(md.contains("dt"));
1709    }
1710
1711    #[test]
1712    fn test_report_builder_to_latex() {
1713        let tex = ReportBuilder::new("LaTeX Report")
1714            .description("Abstract text")
1715            .to_latex();
1716        assert!(tex.contains("\\documentclass"));
1717        assert!(tex.contains("Abstract text"));
1718    }
1719
1720    #[test]
1721    fn test_report_builder_section_in_output() {
1722        let mut sec = ReportSection::new("Methods");
1723        sec.append_text("We used finite differences.");
1724        let html = ReportBuilder::new("Report").section(sec).to_html();
1725        assert!(html.contains("Methods"));
1726        assert!(html.contains("finite differences"));
1727    }
1728
1729    // ── html_escape helper ────────────────────────────────────────────────────
1730
1731    #[test]
1732    fn test_html_escape_ampersand() {
1733        assert_eq!(html_escape("a & b"), "a &amp; b");
1734    }
1735
1736    #[test]
1737    fn test_html_escape_angle_brackets() {
1738        assert_eq!(html_escape("<tag>"), "&lt;tag&gt;");
1739    }
1740
1741    #[test]
1742    fn test_html_escape_quote() {
1743        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
1744    }
1745
1746    #[test]
1747    fn test_html_escape_plain_text() {
1748        assert_eq!(html_escape("hello world"), "hello world");
1749    }
1750
1751    // ── markdown_table helper ─────────────────────────────────────────────────
1752
1753    #[test]
1754    fn test_markdown_table_empty_headers() {
1755        let t = DataTable::new("Cap", vec![]);
1756        let md = markdown_table(&t);
1757        // Should have at least the caption
1758        assert!(md.contains("Cap") || md.is_empty() || md.contains("**Cap**"));
1759    }
1760
1761    #[test]
1762    fn test_markdown_table_with_data() {
1763        let mut t = DataTable::new("T", vec!["A".into(), "B".into()]);
1764        t.add_row(vec![CellValue::Text("x".into()), CellValue::Int(99)]);
1765        let md = markdown_table(&t);
1766        assert!(md.contains("| A |"), "header A missing");
1767        assert!(md.contains("| x |"), "cell x missing");
1768        assert!(md.contains("99"));
1769    }
1770
1771    #[test]
1772    fn test_html_table_with_highlight() {
1773        let mut t = DataTable::new("T", vec!["A".into()]);
1774        t.add_row(vec![CellValue::Int(1)]);
1775        t.highlight_row(0, "highlight");
1776        let html = html_table(&t);
1777        assert!(
1778            html.contains("class=\"highlight\""),
1779            "highlight class missing: {html}"
1780        );
1781    }
1782
1783    // ── ColumnType ────────────────────────────────────────────────────────────
1784
1785    #[test]
1786    fn test_column_type_labels() {
1787        assert_eq!(ColumnType::Integer.label(), "integer");
1788        assert_eq!(ColumnType::Float.label(), "float");
1789        assert_eq!(ColumnType::Text.label(), "text");
1790        assert_eq!(ColumnType::Boolean.label(), "boolean");
1791    }
1792}