use crate::coverage::CoverageReport;
use crate::result::ProbarResult;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
type BlockCoverageData = Vec<(u32, u64, Option<String>)>;
type FileMap = BTreeMap<String, BlockCoverageData>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Theme {
#[default]
Light,
Dark,
HighContrast,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlReportConfig {
pub title: String,
pub highlight_uncovered: bool,
pub include_branch_coverage: bool,
pub theme: Theme,
pub show_line_numbers: bool,
}
impl Default for HtmlReportConfig {
fn default() -> Self {
Self {
title: "Coverage Report".to_string(),
highlight_uncovered: true,
include_branch_coverage: false,
theme: Theme::Light,
show_line_numbers: true,
}
}
}
impl HtmlReportConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn with_highlight_uncovered(mut self, highlight: bool) -> Self {
self.highlight_uncovered = highlight;
self
}
#[must_use]
pub fn with_branch_coverage(mut self, include: bool) -> Self {
self.include_branch_coverage = include;
self
}
#[must_use]
pub fn with_theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
#[must_use]
pub fn with_line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
self
}
}
#[derive(Debug)]
pub struct HtmlFormatter<'a> {
report: &'a CoverageReport,
config: HtmlReportConfig,
}
impl<'a> HtmlFormatter<'a> {
#[must_use]
pub fn new(report: &'a CoverageReport) -> Self {
Self {
report,
config: HtmlReportConfig::default(),
}
}
#[must_use]
pub fn with_config(report: &'a CoverageReport, config: HtmlReportConfig) -> Self {
Self { report, config }
}
#[must_use]
pub fn generate(&self) -> String {
let summary = self.report.summary();
let files = self.group_by_file();
let css = Self::generate_css();
let summary_html = Self::generate_summary_section(&summary);
let files_html = Self::generate_files_section(&files);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{css}</style>
</head>
<body class="{theme_class}">
<header>
<h1>{title}</h1>
<p>Generated by Probar</p>
</header>
<main>
{summary_html}
{files_html}
</main>
<footer>
<p>Probar Coverage Report</p>
</footer>
</body>
</html>"#,
title = self.config.title,
css = css,
theme_class = self.theme_class(),
summary_html = summary_html,
files_html = files_html,
)
}
pub fn save(&self, output_dir: &Path) -> ProbarResult<()> {
std::fs::create_dir_all(output_dir)?;
let index_path = output_dir.join("index.html");
let content = self.generate();
std::fs::write(index_path, content)?;
Ok(())
}
fn theme_class(&self) -> &'static str {
match self.config.theme {
Theme::Light => "theme-light",
Theme::Dark => "theme-dark",
Theme::HighContrast => "theme-high-contrast",
}
}
fn generate_css() -> &'static str {
r#"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; padding: 20px; }
.theme-light { background: #fff; color: #333; }
.theme-dark { background: #1e1e1e; color: #d4d4d4; }
.theme-high-contrast { background: #000; color: #fff; }
header { margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid #ccc; }
h1 { font-size: 24px; }
h2 { font-size: 18px; margin: 20px 0 10px; }
.summary { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
.summary-card { padding: 15px 20px; border-radius: 8px; min-width: 150px; }
.theme-light .summary-card { background: #f5f5f5; }
.theme-dark .summary-card { background: #2d2d2d; }
.summary-card h3 { font-size: 14px; color: #666; }
.theme-dark .summary-card h3 { color: #999; }
.summary-card .value { font-size: 28px; font-weight: bold; }
.coverage-bar { height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden; margin: 10px 0; }
.coverage-fill { height: 100%; background: linear-gradient(90deg, #4caf50, #8bc34a); }
.file-list { margin: 20px 0; }
.file-item { padding: 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.theme-dark .file-item { border-color: #444; }
.file-name { font-family: monospace; }
.file-coverage { font-weight: bold; }
.covered { color: #4caf50; }
.uncovered { color: #f44336; }
footer { margin-top: 40px; padding-top: 10px; border-top: 1px solid #ccc; color: #666; font-size: 12px; }
"#
}
fn generate_summary_section(summary: &crate::coverage::CoverageSummary) -> String {
let coverage_color = if summary.coverage_percent >= 80.0 {
"covered"
} else if summary.coverage_percent >= 50.0 {
""
} else {
"uncovered"
};
format!(
r#"<section class="summary">
<div class="summary-card">
<h3>Total Blocks</h3>
<div class="value">{total}</div>
</div>
<div class="summary-card">
<h3>Covered</h3>
<div class="value covered">{covered}</div>
</div>
<div class="summary-card">
<h3>Coverage</h3>
<div class="value {color}">{percent:.1}%</div>
</div>
</section>
<div class="coverage-bar">
<div class="coverage-fill" style="width: {percent}%"></div>
</div>"#,
total = summary.total_blocks,
covered = summary.covered_blocks,
percent = summary.coverage_percent,
color = coverage_color,
)
}
fn generate_files_section(files: &FileMap) -> String {
use std::fmt::Write;
let mut html = String::from("<section class=\"file-list\"><h2>Files</h2>");
for (file, blocks) in files {
let covered = blocks.iter().filter(|(_, count, _)| *count > 0).count();
let total = blocks.len();
let percent = if total > 0 {
(covered as f64 / total as f64) * 100.0
} else {
100.0
};
let color = if percent >= 80.0 {
"covered"
} else {
"uncovered"
};
let _ = write!(
html,
r#"<div class="file-item">
<span class="file-name">{file}</span>
<span class="file-coverage {color}">{covered}/{total} ({percent:.1}%)</span>
</div>"#,
);
}
html.push_str("</section>");
html
}
fn group_by_file(&self) -> FileMap {
let mut files: FileMap = BTreeMap::new();
for block in self.report.block_coverages() {
let file = block.source_location.as_ref().map_or_else(
|| "unknown".to_string(),
|loc| loc.split(':').next().unwrap_or("unknown").to_string(),
);
let line = block.source_location.as_ref().map_or(0, |loc| {
loc.split(':')
.nth(1)
.and_then(|l| l.parse().ok())
.unwrap_or(0)
});
files
.entry(file)
.or_default()
.push((line, block.hit_count, block.function_name));
}
files
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::coverage::BlockId;
fn create_test_report() -> CoverageReport {
let mut report = CoverageReport::new(5);
report.set_session_name("test_session");
report.record_hits(BlockId::new(0), 10);
report.record_hits(BlockId::new(1), 5);
report.record_hits(BlockId::new(2), 0);
report.record_hits(BlockId::new(3), 3);
report.record_hits(BlockId::new(4), 0);
report.set_source_location(BlockId::new(0), "src/game.rs:10");
report.set_source_location(BlockId::new(1), "src/game.rs:15");
report.set_source_location(BlockId::new(2), "src/game.rs:20");
report.set_source_location(BlockId::new(3), "src/player.rs:5");
report.set_source_location(BlockId::new(4), "src/player.rs:10");
report
}
mod config_tests {
use super::*;
#[test]
fn test_default_config() {
let config = HtmlReportConfig::default();
assert_eq!(config.title, "Coverage Report");
assert!(config.highlight_uncovered);
assert!(!config.include_branch_coverage);
assert_eq!(config.theme, Theme::Light);
assert!(config.show_line_numbers);
}
#[test]
fn test_config_with_title() {
let config = HtmlReportConfig::new().with_title("My Report");
assert_eq!(config.title, "My Report");
}
#[test]
fn test_config_with_theme() {
let config = HtmlReportConfig::new().with_theme(Theme::Dark);
assert_eq!(config.theme, Theme::Dark);
}
#[test]
fn test_config_chained_builders() {
let config = HtmlReportConfig::new()
.with_title("Test")
.with_theme(Theme::HighContrast)
.with_highlight_uncovered(false)
.with_branch_coverage(true)
.with_line_numbers(false);
assert_eq!(config.title, "Test");
assert_eq!(config.theme, Theme::HighContrast);
assert!(!config.highlight_uncovered);
assert!(config.include_branch_coverage);
assert!(!config.show_line_numbers);
}
}
mod formatter_tests {
use super::*;
#[test]
fn test_html_formatter_new() {
let report = CoverageReport::new(10);
let formatter = HtmlFormatter::new(&report);
assert_eq!(formatter.config.title, "Coverage Report");
}
#[test]
fn test_html_formatter_with_config() {
let report = CoverageReport::new(10);
let config = HtmlReportConfig::new().with_title("Custom Title");
let formatter = HtmlFormatter::with_config(&report, config);
assert_eq!(formatter.config.title, "Custom Title");
}
#[test]
fn test_generate_contains_html_structure() {
let report = create_test_report();
let formatter = HtmlFormatter::new(&report);
let output = formatter.generate();
assert!(output.contains("<!DOCTYPE html>"));
assert!(output.contains("<html"));
assert!(output.contains("</html>"));
assert!(output.contains("<head>"));
assert!(output.contains("<body"));
assert!(output.contains("<style>"));
}
#[test]
fn test_generate_contains_title() {
let report = create_test_report();
let config = HtmlReportConfig::new().with_title("My Coverage");
let formatter = HtmlFormatter::with_config(&report, config);
let output = formatter.generate();
assert!(output.contains("<title>My Coverage</title>"));
}
#[test]
fn test_generate_contains_summary() {
let report = create_test_report();
let formatter = HtmlFormatter::new(&report);
let output = formatter.generate();
assert!(output.contains("Total Blocks"));
assert!(output.contains("Covered"));
assert!(output.contains("Coverage"));
}
#[test]
fn test_generate_contains_files() {
let report = create_test_report();
let formatter = HtmlFormatter::new(&report);
let output = formatter.generate();
assert!(output.contains("src/game.rs"));
assert!(output.contains("src/player.rs"));
}
#[test]
fn test_theme_class() {
let report = CoverageReport::new(0);
let light = HtmlFormatter::with_config(
&report,
HtmlReportConfig::new().with_theme(Theme::Light),
);
assert_eq!(light.theme_class(), "theme-light");
let dark = HtmlFormatter::with_config(
&report,
HtmlReportConfig::new().with_theme(Theme::Dark),
);
assert_eq!(dark.theme_class(), "theme-dark");
let hc = HtmlFormatter::with_config(
&report,
HtmlReportConfig::new().with_theme(Theme::HighContrast),
);
assert_eq!(hc.theme_class(), "theme-high-contrast");
}
#[test]
fn test_save_creates_directory_and_file() {
let report = create_test_report();
let formatter = HtmlFormatter::new(&report);
let temp_dir = tempfile::tempdir().unwrap();
let output_dir = temp_dir.path().join("coverage_report");
formatter.save(&output_dir).unwrap();
assert!(output_dir.exists());
assert!(output_dir.join("index.html").exists());
}
}
mod theme_tests {
use super::*;
#[test]
fn test_theme_default() {
let theme = Theme::default();
assert_eq!(theme, Theme::Light);
}
#[test]
fn test_theme_variants() {
let _ = Theme::Light;
let _ = Theme::Dark;
let _ = Theme::HighContrast;
}
}
}