diff --git a/src/cli.rs b/src/cli.rs index 5dbc56799..5d0d799b3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -311,6 +311,15 @@ fn build_command() -> Command { .help("Export the timing summary statistics as an Emacs org-mode table to the given FILE. \ The output time unit can be changed using the --time-unit option."), ) + .arg( + Arg::new("export-html") + .long("export-html") + .action(ArgAction::Set) + .value_name("FILE") + .value_hint(ValueHint::FilePath) + .help("Export the timing summary statistics as an HTML page with interactive charts to the given FILE. \ + The charts include a boxplot of all results, as well as histograms for individual commands."), + ) .arg( Arg::new("show-output") .long("show-output") diff --git a/src/export/html.rs b/src/export/html.rs new file mode 100644 index 000000000..865b07351 --- /dev/null +++ b/src/export/html.rs @@ -0,0 +1,219 @@ +use super::Exporter; +use crate::benchmark::benchmark_result::BenchmarkResult; +use crate::benchmark::relative_speed; +use crate::options::SortOrder; +use crate::util::units::Unit; + +use anyhow::Result; +use serde_json; + +/// HTML exporter for benchmark results. +/// +/// Generates a standalone HTML file with interactive visualizations using Plotly.js +#[derive(Default)] +pub struct HtmlExporter {} + +impl Exporter for HtmlExporter { + fn serialize( + &self, + results: &[BenchmarkResult], + unit: Option, + sort_order: SortOrder, + ) -> Result> { + // Include static assets + let template = include_str!("html_template.html"); + let css = include_str!("html_styles.css"); + let js = include_str!("html_renderer.js"); + + // Build the HTML document with embedded resources + let mut html = template.to_string(); + html = html.replace("/* CSS will be embedded here */", css); + html = html.replace("// JS will be embedded here", js); + + // Determine the appropriate unit if not specified + let unit = unit.unwrap_or_else(|| determine_unit_from_results(results)); + + // Compute relative speeds and sort results + let entries = relative_speed::compute(results, sort_order); + + // Get the reference command if there is one + let reference_command = entries + .iter() + .find(|e| e.is_reference) + .map_or("", |e| &e.result.command); + + // Serialize benchmark data to JSON for JavaScript consumption + let json_data = serde_json::to_string( + &entries + .iter() + .map(|entry| &entry.result) + .collect::>(), + )?; + + // Replace placeholder with benchmark data and unit information + let data_script = format!( + "const benchmarkData = {};\n\ + const unitShortName = \"{}\";\n\ + const unitName = \"{}\";\n\ + const referenceCommand = \"{}\";\n\ + const unitFactor = {};", + json_data, + get_unit_short_name(unit), + get_unit_name(unit), + reference_command, + get_unit_factor(unit) + ); + + html = html.replace("", &data_script); + + Ok(html.into_bytes()) + } +} + +/// Returns the full name of a time unit +fn get_unit_name(unit: Unit) -> &'static str { + match unit { + Unit::Second => "second", + Unit::MilliSecond => "millisecond", + Unit::MicroSecond => "microsecond", + } +} + +/// Returns the abbreviated symbol for a time unit +fn get_unit_short_name(unit: Unit) -> &'static str { + match unit { + Unit::Second => "s", + Unit::MilliSecond => "ms", + Unit::MicroSecond => "μs", + } +} + +/// Returns the conversion factor from seconds to the specified unit +fn get_unit_factor(unit: Unit) -> f64 { + match unit { + Unit::Second => 1.0, + Unit::MilliSecond => 1000.0, + Unit::MicroSecond => 1000000.0, + } +} + +/// Automatically determines the most appropriate time unit based on benchmark results +fn determine_unit_from_results(results: &[BenchmarkResult]) -> Unit { + results + .first() + .map(|first_result| { + // Choose unit based on the magnitude of the mean time + let mean = first_result.mean; + if mean < 0.001 { + Unit::MicroSecond + } else if mean < 1.0 { + Unit::MilliSecond + } else { + Unit::Second + } + }) + .unwrap_or(Unit::Second) // Default to seconds if no results +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn test_html_export() { + // Create sample benchmark results + let results = vec![ + create_test_benchmark("test command 1", 1.5, None), + create_test_benchmark_with_param("test command 2", 2.5, "size", "10"), + ]; + + // Create HTML exporter + let exporter = HtmlExporter::default(); + + // Test with seconds unit + let html = export_and_get_html(&exporter, &results, Unit::Second); + + // Verify HTML structure and content + assert_html_structure(&html); + assert_contains_benchmark_data(&html, &results); + assert_unit_information(&html, "s", "second", "1"); + + // Test with milliseconds unit + let html = export_and_get_html(&exporter, &results, Unit::MilliSecond); + assert_unit_information(&html, "ms", "millisecond", "1000"); + } + + /// Helper function to create a test benchmark result + fn create_test_benchmark( + command: &str, + mean: f64, + parameters: Option>, + ) -> BenchmarkResult { + BenchmarkResult { + command: command.to_string(), + command_with_unused_parameters: command.to_string(), + mean, + stddev: Some(mean * 0.1), + median: mean * 0.99, + min: mean * 0.8, + max: mean * 1.2, + user: mean * 0.9, + system: mean * 0.1, + memory_usage_byte: None, + times: Some(vec![mean * 0.8, mean * 0.9, mean, mean * 1.1, mean * 1.2]), + exit_codes: vec![Some(0); 5], + parameters: parameters.unwrap_or_default(), + } + } + + /// Helper function to create a test benchmark with a parameter + fn create_test_benchmark_with_param( + command: &str, + mean: f64, + param_name: &str, + param_value: &str, + ) -> BenchmarkResult { + let mut params = BTreeMap::new(); + params.insert(param_name.to_string(), param_value.to_string()); + create_test_benchmark(command, mean, Some(params)) + } + + /// Helper function to export benchmark results and get HTML + fn export_and_get_html( + exporter: &HtmlExporter, + results: &[BenchmarkResult], + unit: Unit, + ) -> String { + let html_bytes = exporter + .serialize(results, Some(unit), SortOrder::MeanTime) + .expect("HTML export failed"); + String::from_utf8(html_bytes).expect("HTML is not valid UTF-8") + } + + /// Assert that the HTML has the expected structure + fn assert_html_structure(html: &str) { + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("Hyperfine Benchmark Results")); + assert!(html.contains("
")); + assert!(html.contains("function renderSummaryTable()")); + assert!(html.contains("function renderBoxplot()")); + assert!(html.contains("font-family: Arial, sans-serif")); + } + + /// Assert that the HTML contains the benchmark data + fn assert_contains_benchmark_data(html: &str, results: &[BenchmarkResult]) { + assert!(html.contains("const benchmarkData =")); + for result in results { + assert!(html.contains(&result.command)); + } + } + + /// Assert unit information in the HTML + fn assert_unit_information(html: &str, short_name: &str, name: &str, factor: &str) { + assert!(html.contains(&format!("const unitShortName = \"{}\"", short_name))); + assert!(html.contains(&format!("const unitName = \"{}\"", name))); + assert!(html.contains(&format!("const unitFactor = {}", factor))); + } +} diff --git a/src/export/html_renderer.js b/src/export/html_renderer.js new file mode 100644 index 000000000..770170f8f --- /dev/null +++ b/src/export/html_renderer.js @@ -0,0 +1,535 @@ +/** + * Hyperfine Benchmark Results - JavaScript Renderer + * + * This script processes benchmark data and generates interactive visualizations + * using Plotly.js. It provides tabbed sections for different analysis views. + */ + +// Initialize when the DOM is fully loaded +document.addEventListener("DOMContentLoaded", function () { + // Process the data and compute derived metrics + processData(); + + // Set up the tabbed interface + initTabs(); + + // Render all visualizations + renderSummaryTable(); + renderBoxplot(); + renderHistograms(); + renderProgressionPlot(); + renderAdvancedStats(); + detectAndRenderParameters(); +}); + +/** + * Process benchmark data and calculate relative speeds + */ +function processData() { + // Helper to check if a value is a number + const isNumber = (x) => typeof x === "number" && !isNaN(x); + + // Convert times to selected unit + benchmarkData.forEach((result) => { + // Apply unit conversion to main statistics + if (isNumber(result.mean)) result.meanInUnit = result.mean * unitFactor; + if (isNumber(result.min)) result.minInUnit = result.min * unitFactor; + if (isNumber(result.max)) result.maxInUnit = result.max * unitFactor; + if (isNumber(result.stddev)) + result.stddevInUnit = result.stddev * unitFactor; + + // Convert timing data + if (result.times) { + result.timesInUnit = result.times.map((t) => t * unitFactor); + } + }); + + // Find the reference or fastest result for relative comparison + let referenceResult; + if (referenceCommand) { + referenceResult = benchmarkData.find((r) => r.command === referenceCommand); + } + + if (!referenceResult) { + // If no reference was specified, use the fastest result + const fastestMean = Math.min(...benchmarkData.map((r) => r.mean)); + referenceResult = benchmarkData.find((r) => r.mean === fastestMean); + } + + // Mark reference and calculate relative speeds + benchmarkData.forEach((result) => { + result.is_reference = result.command === referenceResult.command; + result.relative_speed = result.mean / referenceResult.mean; + + // Calculate relative stddev if both results have stddev + if (result.stddev && referenceResult.stddev) { + // Use propagation of uncertainty formula for division + result.relative_stddev = + result.relative_speed * + Math.sqrt( + Math.pow(result.stddev / result.mean, 2) + + Math.pow(referenceResult.stddev / referenceResult.mean, 2) + ); + } + }); +} + +/** + * Initialize tabbed interface + */ +function initTabs() { + const tabButtons = document.querySelectorAll(".tab-button"); + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + // Remove active class from all buttons and contents + tabButtons.forEach((btn) => btn.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach((content) => { + content.classList.remove("active"); + }); + + // Add active class to clicked button and its content + button.classList.add("active"); + const tabId = button.getAttribute("data-tab"); + document.getElementById(tabId + "-tab").classList.add("active"); + + // Trigger resize event to make sure plots render correctly + window.dispatchEvent(new Event("resize")); + }); + }); +} + +/** + * Format a number according to the selected unit with specified decimals + */ +function formatValue(value, decimals = 3) { + if (value === undefined || value === null) return ""; + return value.toFixed(decimals); +} + +/** + * Create the summary table with benchmark results + */ +function renderSummaryTable() { + let tableHtml = ` +

Summary

+ + + + + + + + + + + + `; + + benchmarkData.forEach((result) => { + const rowClass = result.is_reference ? "reference" : ""; + const stddev = result.stddev + ? ` ± ${formatValue(result.stddevInUnit)}` + : ""; + const relStddev = result.relative_stddev + ? ` ± ${formatValue( + result.relative_stddev, + 2 + )}` + : ""; + + tableHtml += ` + + + + + + + + `; + }); + + tableHtml += ` + +
CommandMean [${unitShortName}]Min [${unitShortName}]Max [${unitShortName}]Relative
${escapeHtml(result.command)}${formatValue(result.meanInUnit)}${stddev}${formatValue(result.minInUnit)}${formatValue(result.maxInUnit)}${formatValue(result.relative_speed, 2)}x${relStddev}
+ `; + + document.getElementById("summary-table").innerHTML = tableHtml; +} + +/** + * Create the boxplot comparison + */ +function renderBoxplot() { + const boxplotData = benchmarkData.map((result) => { + return { + y: result.timesInUnit || [], + type: "box", + name: result.command, + boxpoints: "all", + jitter: 0.3, + pointpos: 0, + }; + }); + + const layout = { + title: `Runtime Comparison (${unitName})`, + yaxis: { title: `Time [${unitShortName}]` }, + margin: { l: 60, r: 30, t: 50, b: 50 }, + autosize: true, + responsive: true, + }; + + const config = { + responsive: true, + displayModeBar: false, + }; + + Plotly.newPlot("boxplot", boxplotData, layout, config); +} + +/** + * Create histograms for each command + */ +function renderHistograms() { + const histogramsContainer = document.getElementById("histograms"); + histogramsContainer.innerHTML = ""; + + benchmarkData.forEach((result, index) => { + // Create a div for this histogram + const chartDiv = document.createElement("div"); + chartDiv.className = "chart-box"; + chartDiv.innerHTML = `

${escapeHtml( + result.command + )}

`; + histogramsContainer.appendChild(chartDiv); + + // Only create histogram if we have timing data + if (result.timesInUnit && result.timesInUnit.length > 0) { + const histData = [ + { + x: result.timesInUnit, + type: "histogram", + marker: { color: "rgba(100, 200, 102, 0.7)" }, + }, + ]; + + const layout = { + title: "Distribution of runtimes", + xaxis: { title: `Time [${unitShortName}]` }, + yaxis: { title: "Count" }, + margin: { l: 50, r: 30, t: 50, b: 50 }, + autosize: true, + responsive: true, + }; + + const config = { + responsive: true, + displayModeBar: false, + }; + + Plotly.newPlot(`histogram-${index}`, histData, layout, config); + } else { + document.getElementById(`histogram-${index}`).innerHTML = + "

No detailed timing data available for this command.

"; + } + }); +} + +/** + * Create time progression plot with moving averages + */ +function renderProgressionPlot() { + const plotDiv = document.getElementById("progression"); + + // Define color palette for consistent colors + const defaultColors = [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]; + + const traces = []; + const movingAverageTraces = []; + const colorMap = {}; + + benchmarkData.forEach((result, idx) => { + if (result.timesInUnit && result.timesInUnit.length > 0) { + // Create array of iteration indices + const iterations = Array.from( + { length: result.timesInUnit.length }, + (_, i) => i + 1 + ); + + // Assign a color to this command + colorMap[result.command] = defaultColors[idx % defaultColors.length]; + + // Create scatter trace for individual points + traces.push({ + x: iterations, + y: result.timesInUnit, + mode: "markers", + name: result.command, + marker: { + color: colorMap[result.command], + size: 5, + opacity: 0.7, + }, + }); + + // Calculate moving average with adaptive window size + const windowSize = Math.max(3, Math.floor(result.timesInUnit.length / 5)); + const movingAvg = calculateMovingAverage(result.timesInUnit, windowSize); + + // Create line trace for moving average with same color + movingAverageTraces.push({ + x: iterations, + y: movingAvg, + mode: "lines", + name: `${result.command} (moving avg)`, + line: { + color: colorMap[result.command], + width: 2, + dash: "solid", + }, + showlegend: false, // Don't show in legend to avoid cluttering + }); + } + }); + + // Combine all traces + const allTraces = [...traces, ...movingAverageTraces]; + + const layout = { + title: "Time Progression", + xaxis: { title: "Iteration number" }, + yaxis: { title: `Time [${unitShortName}]` }, + hovermode: "closest", + autosize: true, + responsive: true, + }; + + const config = { + responsive: true, + displayModeBar: false, + }; + + Plotly.newPlot(plotDiv, allTraces, layout, config); +} + +/** + * Calculate moving average of a time series + */ +function calculateMovingAverage(values, windowSize) { + const result = []; + + for (let i = 0; i < values.length; i++) { + let windowStart = Math.max(0, i - Math.floor(windowSize / 2)); + let windowEnd = Math.min(values.length, i + Math.ceil(windowSize / 2)); + let sum = 0; + + for (let j = windowStart; j < windowEnd; j++) { + sum += values[j]; + } + + result.push(sum / (windowEnd - windowStart)); + } + + return result; +} + +/** + * Create advanced statistics cards + */ +function renderAdvancedStats() { + const container = document.getElementById("stats-container"); + + benchmarkData.forEach((result) => { + if (!result.timesInUnit || result.timesInUnit.length === 0) return; + + const times = result.timesInUnit; + const sorted = [...times].sort((a, b) => a - b); + + // Calculate key statistics + const mean = times.reduce((a, b) => a + b) / times.length; + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + const median = quantile(sorted, 0.5); + const p5 = quantile(sorted, 0.05); + const p25 = quantile(sorted, 0.25); + const p75 = quantile(sorted, 0.75); + const p95 = quantile(sorted, 0.95); + const iqr = p75 - p25; + + // Calculate variance and standard deviation + const variance = + times.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / + (times.length - 1); + const stddev = Math.sqrt(variance); + + // Create stats card with formatted values + const card = document.createElement("div"); + card.className = "stats-card"; + + card.innerHTML = ` +
${escapeHtml(result.command)}
+
+ Runs: + ${times.length} +
+
+ Mean: + ${formatValue(mean)} ${unitShortName} +
+
+ Std dev: + ${formatValue(stddev)} ${unitShortName} +
+
+ Median: + ${formatValue(median)} ${unitShortName} +
+
+ Min: + ${formatValue(min)} ${unitShortName} +
+
+ Max: + ${formatValue(max)} ${unitShortName} +
+
+ P_05..P_95: + ${formatValue(p5)}..${formatValue( + p95 + )} ${unitShortName} +
+
+ P_25..P_75 (IQR): + ${formatValue(p25)}..${formatValue( + p75 + )} ${unitShortName} (${formatValue(iqr)} ${unitShortName}) +
+ `; + + container.appendChild(card); + }); +} + +/** + * Compute the q-th quantile from sorted array values + * Uses the R7 method, which is default in NumPy/SciPy + * + * @param {Array} sorted - Sorted array of values + * @param {number} q - Quantile to compute (0 <= q <= 1) + * @return {number} The q-th quantile value + */ +function quantile(sorted, q) { + if (q <= 0) return sorted[0]; + if (q >= 1) return sorted[sorted.length - 1]; + + const n = sorted.length; + const index = (n - 1) * q; + const low = Math.floor(index); + const high = Math.ceil(index); + const h = index - low; + + return (1 - h) * sorted[low] + h * sorted[high]; +} + +/** + * Detect parameters in commands and render parameter plot + */ +function detectAndRenderParameters() { + // Extract unique parameter names + const parameterNames = new Set(); + benchmarkData.forEach((result) => { + if (result.parameters) { + Object.keys(result.parameters).forEach((param) => + parameterNames.add(param) + ); + } + }); + + if (parameterNames.size === 0) return; + + // Show the parameter tab button + document.getElementById("param-tab-button").style.display = "block"; + + // Create plot for each parameter + const plotDiv = document.getElementById("param-plot"); + const traces = []; + + parameterNames.forEach((paramName) => { + // Get results for this parameter + const relevantResults = benchmarkData.filter( + (result) => result.parameters && result.parameters[paramName] + ); + + if (relevantResults.length < 2) return; // Need at least 2 points for a line + + // Create data points + const dataPoints = relevantResults.map((result) => ({ + value: parseFloat(result.parameters[paramName]), + mean: result.mean, + stddev: result.stddev ? result.stddev : 0, + command: result.command, + })); + + // Sort by parameter value + dataPoints.sort((a, b) => a.value - b.value); + + // Create trace for this parameter + traces.push({ + x: dataPoints.map((p) => p.value), + y: dataPoints.map((p) => p.mean), + error_y: { + type: "data", + array: dataPoints.map((p) => p.stddev), + visible: true, + }, + mode: "lines+markers", + type: "scatter", + name: paramName, + hovertemplate: `${paramName}=%{x}: %{y:.3f} ${unitShortName} ± %{error_y.array:.3f}
%{text}`, + text: dataPoints.map((p) => p.command), + }); + }); + + const layout = { + title: "Parameter Analysis", + xaxis: { title: "Parameter Value" }, + yaxis: { + title: `Time [${unitShortName}]`, + rangemode: "tozero", + }, + hovermode: "closest", + autosize: true, + responsive: true, + }; + + const config = { + responsive: true, + displayModeBar: false, + }; + + Plotly.newPlot(plotDiv, traces, layout, config); +} + +/** + * Escape HTML special characters for safe display + */ +function escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(/<\//g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/export/html_styles.css b/src/export/html_styles.css new file mode 100644 index 000000000..49d4aa63b --- /dev/null +++ b/src/export/html_styles.css @@ -0,0 +1,202 @@ +/* + * Hyperfine Benchmark Results - Stylesheet + * Main styles for the benchmark report visualization + */ + +/* Base styles */ +body { + font-family: Arial, sans-serif; + margin: 20px; + padding: 0; + color: #333; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 10px; +} + +h1, +h2, +h3 { + color: #333; + margin-top: 20px; + margin-bottom: 15px; +} + +/* Chart and visualization styles */ +.chart { + height: 400px; + margin-bottom: 30px; + width: 100%; +} + +.chart-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); + gap: 20px; +} + +.chart-box { + width: 100%; +} + +/* Table styles */ +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 30px; + overflow-x: auto; + display: block; +} + +th, +td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +th { + background-color: #f2f2f2; +} + +.reference { + font-weight: bold; +} + +.stddev { + color: #666; + font-size: 0.9em; +} + +/* Tab navigation */ +.tab-container { + margin-top: 20px; +} + +.tab-buttons { + display: flex; + gap: 5px; + margin-bottom: 10px; +} + +.tab-button { + padding: 10px 15px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; +} + +.tab-button:hover { + background: #e9e9e9; +} + +.tab-button.active { + background: #e1e1e1; + font-weight: bold; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Statistics cards */ +.stats-card { + border: 1px solid #ddd; + border-radius: 4px; + padding: 15px; + margin-bottom: 15px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.stats-header { + font-weight: bold; + margin-bottom: 10px; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.stats-row { + display: flex; + justify-content: space-between; + margin-bottom: 3px; +} + +.stats-label { + color: #666; + flex: 1; +} + +.stats-value { + font-family: monospace; + text-align: right; + flex: 1; +} + +/* Footer styling */ +.footer { + margin-top: 40px; + font-size: 0.8em; + color: #666; + text-align: center; + border-top: 1px solid #eee; + padding-top: 10px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .chart { + height: 350px; + } + + .chart-container { + grid-template-columns: 1fr; + } + + body { + margin: 10px; + } + + .tab-buttons { + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 5px; + } + + .tab-button { + flex: 0 0 auto; + white-space: nowrap; + } +} + +@media (max-width: 500px) { + .chart { + height: 300px; + } + + table { + font-size: 14px; + } + + h1 { + font-size: 1.8em; + } + + h2 { + font-size: 1.5em; + } + + h3 { + font-size: 1.2em; + } +} diff --git a/src/export/html_template.html b/src/export/html_template.html new file mode 100644 index 000000000..4f75d55af --- /dev/null +++ b/src/export/html_template.html @@ -0,0 +1,90 @@ + + + + + + + Hyperfine Benchmark Results + + + + + +
+

Hyperfine Benchmark Results

+ + +
+ + +
+
+ + + + + + +
+ + +
+

Comparison

+
+
+ +
+

Individual Results

+
+
+ +
+

Time Progression

+

+ This shows how benchmark times change over iterations. It can help + identify warming effects, thermal throttling, or other trends. +

+
+
+ +
+

Advanced Statistics

+
+
+ +
+

Parameter Analysis

+

This shows how execution time scales with parameter values.

+
+
+
+ + + + + + +
+ + diff --git a/src/export/mod.rs b/src/export/mod.rs index 3947a2e50..aedac2de1 100644 --- a/src/export/mod.rs +++ b/src/export/mod.rs @@ -3,6 +3,7 @@ use std::io::Write; mod asciidoc; mod csv; +mod html; mod json; mod markdown; mod markup; @@ -12,6 +13,7 @@ mod tests; use self::asciidoc::AsciidocExporter; use self::csv::CsvExporter; +use self::html::HtmlExporter; use self::json::JsonExporter; use self::markdown::MarkdownExporter; use self::orgmode::OrgmodeExporter; @@ -32,6 +34,9 @@ pub enum ExportType { /// CSV (comma separated values) format Csv, + /// HTML with interactive charts + Html, + /// JSON format Json, @@ -95,6 +100,7 @@ impl ExportManager { add_exporter("export-csv", ExportType::Csv)?; add_exporter("export-markdown", ExportType::Markdown)?; add_exporter("export-orgmode", ExportType::Orgmode)?; + add_exporter("export-html", ExportType::Html)?; } Ok(export_manager) } @@ -104,6 +110,7 @@ impl ExportManager { let exporter: Box = match export_type { ExportType::Asciidoc => Box::::default(), ExportType::Csv => Box::::default(), + ExportType::Html => Box::::default(), ExportType::Json => Box::::default(), ExportType::Markdown => Box::::default(), ExportType::Orgmode => Box::::default(),