jugar_probar/coverage/formatters/
html.rs1use crate::coverage::CoverageReport;
6use crate::result::ProbarResult;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::path::Path;
10
11type BlockCoverageData = Vec<(u32, u64, Option<String>)>;
13
14type FileMap = BTreeMap<String, BlockCoverageData>;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub enum Theme {
20 #[default]
22 Light,
23 Dark,
25 HighContrast,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct HtmlReportConfig {
32 pub title: String,
34 pub highlight_uncovered: bool,
36 pub include_branch_coverage: bool,
38 pub theme: Theme,
40 pub show_line_numbers: bool,
42}
43
44impl Default for HtmlReportConfig {
45 fn default() -> Self {
46 Self {
47 title: "Coverage Report".to_string(),
48 highlight_uncovered: true,
49 include_branch_coverage: false,
50 theme: Theme::Light,
51 show_line_numbers: true,
52 }
53 }
54}
55
56impl HtmlReportConfig {
57 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn with_title(mut self, title: impl Into<String>) -> Self {
66 self.title = title.into();
67 self
68 }
69
70 #[must_use]
72 pub fn with_highlight_uncovered(mut self, highlight: bool) -> Self {
73 self.highlight_uncovered = highlight;
74 self
75 }
76
77 #[must_use]
79 pub fn with_branch_coverage(mut self, include: bool) -> Self {
80 self.include_branch_coverage = include;
81 self
82 }
83
84 #[must_use]
86 pub fn with_theme(mut self, theme: Theme) -> Self {
87 self.theme = theme;
88 self
89 }
90
91 #[must_use]
93 pub fn with_line_numbers(mut self, show: bool) -> Self {
94 self.show_line_numbers = show;
95 self
96 }
97}
98
99#[derive(Debug)]
101pub struct HtmlFormatter<'a> {
102 report: &'a CoverageReport,
103 config: HtmlReportConfig,
104}
105
106impl<'a> HtmlFormatter<'a> {
107 #[must_use]
109 pub fn new(report: &'a CoverageReport) -> Self {
110 Self {
111 report,
112 config: HtmlReportConfig::default(),
113 }
114 }
115
116 #[must_use]
118 pub fn with_config(report: &'a CoverageReport, config: HtmlReportConfig) -> Self {
119 Self { report, config }
120 }
121
122 #[must_use]
124 pub fn generate(&self) -> String {
125 let summary = self.report.summary();
126 let files = self.group_by_file();
127
128 let css = Self::generate_css();
129 let summary_html = Self::generate_summary_section(&summary);
130 let files_html = Self::generate_files_section(&files);
131
132 format!(
133 r#"<!DOCTYPE html>
134<html lang="en">
135<head>
136 <meta charset="UTF-8">
137 <meta name="viewport" content="width=device-width, initial-scale=1.0">
138 <title>{title}</title>
139 <style>{css}</style>
140</head>
141<body class="{theme_class}">
142 <header>
143 <h1>{title}</h1>
144 <p>Generated by Probar</p>
145 </header>
146 <main>
147 {summary_html}
148 {files_html}
149 </main>
150 <footer>
151 <p>Probar Coverage Report</p>
152 </footer>
153</body>
154</html>"#,
155 title = self.config.title,
156 css = css,
157 theme_class = self.theme_class(),
158 summary_html = summary_html,
159 files_html = files_html,
160 )
161 }
162
163 pub fn save(&self, output_dir: &Path) -> ProbarResult<()> {
169 std::fs::create_dir_all(output_dir)?;
170
171 let index_path = output_dir.join("index.html");
172 let content = self.generate();
173 std::fs::write(index_path, content)?;
174
175 Ok(())
176 }
177
178 fn theme_class(&self) -> &'static str {
180 match self.config.theme {
181 Theme::Light => "theme-light",
182 Theme::Dark => "theme-dark",
183 Theme::HighContrast => "theme-high-contrast",
184 }
185 }
186
187 fn generate_css() -> &'static str {
189 r#"
190 * { box-sizing: border-box; margin: 0; padding: 0; }
191 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; padding: 20px; }
192 .theme-light { background: #fff; color: #333; }
193 .theme-dark { background: #1e1e1e; color: #d4d4d4; }
194 .theme-high-contrast { background: #000; color: #fff; }
195 header { margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid #ccc; }
196 h1 { font-size: 24px; }
197 h2 { font-size: 18px; margin: 20px 0 10px; }
198 .summary { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
199 .summary-card { padding: 15px 20px; border-radius: 8px; min-width: 150px; }
200 .theme-light .summary-card { background: #f5f5f5; }
201 .theme-dark .summary-card { background: #2d2d2d; }
202 .summary-card h3 { font-size: 14px; color: #666; }
203 .theme-dark .summary-card h3 { color: #999; }
204 .summary-card .value { font-size: 28px; font-weight: bold; }
205 .coverage-bar { height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden; margin: 10px 0; }
206 .coverage-fill { height: 100%; background: linear-gradient(90deg, #4caf50, #8bc34a); }
207 .file-list { margin: 20px 0; }
208 .file-item { padding: 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
209 .theme-dark .file-item { border-color: #444; }
210 .file-name { font-family: monospace; }
211 .file-coverage { font-weight: bold; }
212 .covered { color: #4caf50; }
213 .uncovered { color: #f44336; }
214 footer { margin-top: 40px; padding-top: 10px; border-top: 1px solid #ccc; color: #666; font-size: 12px; }
215 "#
216 }
217
218 fn generate_summary_section(summary: &crate::coverage::CoverageSummary) -> String {
220 let coverage_color = if summary.coverage_percent >= 80.0 {
221 "covered"
222 } else if summary.coverage_percent >= 50.0 {
223 ""
224 } else {
225 "uncovered"
226 };
227
228 format!(
229 r#"<section class="summary">
230 <div class="summary-card">
231 <h3>Total Blocks</h3>
232 <div class="value">{total}</div>
233 </div>
234 <div class="summary-card">
235 <h3>Covered</h3>
236 <div class="value covered">{covered}</div>
237 </div>
238 <div class="summary-card">
239 <h3>Coverage</h3>
240 <div class="value {color}">{percent:.1}%</div>
241 </div>
242</section>
243<div class="coverage-bar">
244 <div class="coverage-fill" style="width: {percent}%"></div>
245</div>"#,
246 total = summary.total_blocks,
247 covered = summary.covered_blocks,
248 percent = summary.coverage_percent,
249 color = coverage_color,
250 )
251 }
252
253 fn generate_files_section(files: &FileMap) -> String {
255 use std::fmt::Write;
256 let mut html = String::from("<section class=\"file-list\"><h2>Files</h2>");
257
258 for (file, blocks) in files {
259 let covered = blocks.iter().filter(|(_, count, _)| *count > 0).count();
260 let total = blocks.len();
261 let percent = if total > 0 {
262 (covered as f64 / total as f64) * 100.0
263 } else {
264 100.0
265 };
266
267 let color = if percent >= 80.0 {
268 "covered"
269 } else {
270 "uncovered"
271 };
272
273 let _ = write!(
274 html,
275 r#"<div class="file-item">
276 <span class="file-name">{file}</span>
277 <span class="file-coverage {color}">{covered}/{total} ({percent:.1}%)</span>
278</div>"#,
279 );
280 }
281
282 html.push_str("</section>");
283 html
284 }
285
286 fn group_by_file(&self) -> FileMap {
288 let mut files: FileMap = BTreeMap::new();
289
290 for block in self.report.block_coverages() {
291 let file = block.source_location.as_ref().map_or_else(
292 || "unknown".to_string(),
293 |loc| loc.split(':').next().unwrap_or("unknown").to_string(),
294 );
295
296 let line = block.source_location.as_ref().map_or(0, |loc| {
297 loc.split(':')
298 .nth(1)
299 .and_then(|l| l.parse().ok())
300 .unwrap_or(0)
301 });
302
303 files
304 .entry(file)
305 .or_default()
306 .push((line, block.hit_count, block.function_name));
307 }
308
309 files
310 }
311}
312
313#[cfg(test)]
314#[allow(clippy::unwrap_used, clippy::expect_used)]
315mod tests {
316 use super::*;
317 use crate::coverage::BlockId;
318
319 fn create_test_report() -> CoverageReport {
320 let mut report = CoverageReport::new(5);
321 report.set_session_name("test_session");
322
323 report.record_hits(BlockId::new(0), 10);
324 report.record_hits(BlockId::new(1), 5);
325 report.record_hits(BlockId::new(2), 0);
326 report.record_hits(BlockId::new(3), 3);
327 report.record_hits(BlockId::new(4), 0);
328
329 report.set_source_location(BlockId::new(0), "src/game.rs:10");
330 report.set_source_location(BlockId::new(1), "src/game.rs:15");
331 report.set_source_location(BlockId::new(2), "src/game.rs:20");
332 report.set_source_location(BlockId::new(3), "src/player.rs:5");
333 report.set_source_location(BlockId::new(4), "src/player.rs:10");
334
335 report
336 }
337
338 mod config_tests {
339 use super::*;
340
341 #[test]
342 fn test_default_config() {
343 let config = HtmlReportConfig::default();
344 assert_eq!(config.title, "Coverage Report");
345 assert!(config.highlight_uncovered);
346 assert!(!config.include_branch_coverage);
347 assert_eq!(config.theme, Theme::Light);
348 assert!(config.show_line_numbers);
349 }
350
351 #[test]
352 fn test_config_with_title() {
353 let config = HtmlReportConfig::new().with_title("My Report");
354 assert_eq!(config.title, "My Report");
355 }
356
357 #[test]
358 fn test_config_with_theme() {
359 let config = HtmlReportConfig::new().with_theme(Theme::Dark);
360 assert_eq!(config.theme, Theme::Dark);
361 }
362
363 #[test]
364 fn test_config_chained_builders() {
365 let config = HtmlReportConfig::new()
366 .with_title("Test")
367 .with_theme(Theme::HighContrast)
368 .with_highlight_uncovered(false)
369 .with_branch_coverage(true)
370 .with_line_numbers(false);
371
372 assert_eq!(config.title, "Test");
373 assert_eq!(config.theme, Theme::HighContrast);
374 assert!(!config.highlight_uncovered);
375 assert!(config.include_branch_coverage);
376 assert!(!config.show_line_numbers);
377 }
378 }
379
380 mod formatter_tests {
381 use super::*;
382
383 #[test]
384 fn test_html_formatter_new() {
385 let report = CoverageReport::new(10);
386 let formatter = HtmlFormatter::new(&report);
387 assert_eq!(formatter.config.title, "Coverage Report");
388 }
389
390 #[test]
391 fn test_html_formatter_with_config() {
392 let report = CoverageReport::new(10);
393 let config = HtmlReportConfig::new().with_title("Custom Title");
394 let formatter = HtmlFormatter::with_config(&report, config);
395 assert_eq!(formatter.config.title, "Custom Title");
396 }
397
398 #[test]
399 fn test_generate_contains_html_structure() {
400 let report = create_test_report();
401 let formatter = HtmlFormatter::new(&report);
402 let output = formatter.generate();
403
404 assert!(output.contains("<!DOCTYPE html>"));
405 assert!(output.contains("<html"));
406 assert!(output.contains("</html>"));
407 assert!(output.contains("<head>"));
408 assert!(output.contains("<body"));
409 assert!(output.contains("<style>"));
410 }
411
412 #[test]
413 fn test_generate_contains_title() {
414 let report = create_test_report();
415 let config = HtmlReportConfig::new().with_title("My Coverage");
416 let formatter = HtmlFormatter::with_config(&report, config);
417 let output = formatter.generate();
418
419 assert!(output.contains("<title>My Coverage</title>"));
420 }
421
422 #[test]
423 fn test_generate_contains_summary() {
424 let report = create_test_report();
425 let formatter = HtmlFormatter::new(&report);
426 let output = formatter.generate();
427
428 assert!(output.contains("Total Blocks"));
429 assert!(output.contains("Covered"));
430 assert!(output.contains("Coverage"));
431 }
432
433 #[test]
434 fn test_generate_contains_files() {
435 let report = create_test_report();
436 let formatter = HtmlFormatter::new(&report);
437 let output = formatter.generate();
438
439 assert!(output.contains("src/game.rs"));
440 assert!(output.contains("src/player.rs"));
441 }
442
443 #[test]
444 fn test_theme_class() {
445 let report = CoverageReport::new(0);
446
447 let light = HtmlFormatter::with_config(
448 &report,
449 HtmlReportConfig::new().with_theme(Theme::Light),
450 );
451 assert_eq!(light.theme_class(), "theme-light");
452
453 let dark = HtmlFormatter::with_config(
454 &report,
455 HtmlReportConfig::new().with_theme(Theme::Dark),
456 );
457 assert_eq!(dark.theme_class(), "theme-dark");
458
459 let hc = HtmlFormatter::with_config(
460 &report,
461 HtmlReportConfig::new().with_theme(Theme::HighContrast),
462 );
463 assert_eq!(hc.theme_class(), "theme-high-contrast");
464 }
465
466 #[test]
467 fn test_save_creates_directory_and_file() {
468 let report = create_test_report();
469 let formatter = HtmlFormatter::new(&report);
470
471 let temp_dir = tempfile::tempdir().unwrap();
472 let output_dir = temp_dir.path().join("coverage_report");
473
474 formatter.save(&output_dir).unwrap();
475
476 assert!(output_dir.exists());
477 assert!(output_dir.join("index.html").exists());
478 }
479 }
480
481 mod theme_tests {
482 use super::*;
483
484 #[test]
485 fn test_theme_default() {
486 let theme = Theme::default();
487 assert_eq!(theme, Theme::Light);
488 }
489
490 #[test]
491 fn test_theme_variants() {
492 let _ = Theme::Light;
493 let _ = Theme::Dark;
494 let _ = Theme::HighContrast;
495 }
496 }
497}