Skip to main content

jugar_probar/coverage/formatters/
html.rs

1//! HTML Coverage Report Formatter (Feature 12)
2//!
3//! Interactive HTML coverage reports with source code highlighting.
4
5use crate::coverage::CoverageReport;
6use crate::result::ProbarResult;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::path::Path;
10
11/// Block coverage data: (line, hit_count, function_name)
12type BlockCoverageData = Vec<(u32, u64, Option<String>)>;
13
14/// Files grouped by path
15type FileMap = BTreeMap<String, BlockCoverageData>;
16
17/// Color theme for HTML report
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub enum Theme {
20    /// Light theme (default)
21    #[default]
22    Light,
23    /// Dark theme
24    Dark,
25    /// High contrast theme
26    HighContrast,
27}
28
29/// Configuration for HTML coverage report
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct HtmlReportConfig {
32    /// Report title
33    pub title: String,
34    /// Highlight uncovered lines
35    pub highlight_uncovered: bool,
36    /// Include branch coverage information
37    pub include_branch_coverage: bool,
38    /// Color theme
39    pub theme: Theme,
40    /// Show line numbers
41    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    /// Create new config with default settings
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Set the report title
64    #[must_use]
65    pub fn with_title(mut self, title: impl Into<String>) -> Self {
66        self.title = title.into();
67        self
68    }
69
70    /// Set highlight uncovered option
71    #[must_use]
72    pub fn with_highlight_uncovered(mut self, highlight: bool) -> Self {
73        self.highlight_uncovered = highlight;
74        self
75    }
76
77    /// Set branch coverage option
78    #[must_use]
79    pub fn with_branch_coverage(mut self, include: bool) -> Self {
80        self.include_branch_coverage = include;
81        self
82    }
83
84    /// Set the theme
85    #[must_use]
86    pub fn with_theme(mut self, theme: Theme) -> Self {
87        self.theme = theme;
88        self
89    }
90
91    /// Set show line numbers option
92    #[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/// HTML format report generator
100#[derive(Debug)]
101pub struct HtmlFormatter<'a> {
102    report: &'a CoverageReport,
103    config: HtmlReportConfig,
104}
105
106impl<'a> HtmlFormatter<'a> {
107    /// Create a new HTML formatter with default config
108    #[must_use]
109    pub fn new(report: &'a CoverageReport) -> Self {
110        Self {
111            report,
112            config: HtmlReportConfig::default(),
113        }
114    }
115
116    /// Create with custom configuration
117    #[must_use]
118    pub fn with_config(report: &'a CoverageReport, config: HtmlReportConfig) -> Self {
119        Self { report, config }
120    }
121
122    /// Generate the HTML report as a string
123    #[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    /// Save the HTML report to a directory
164    ///
165    /// # Errors
166    ///
167    /// Returns error if file write fails
168    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    /// Get CSS class for current theme
179    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    /// Generate CSS styles
188    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    /// Generate summary section HTML
219    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    /// Generate files section HTML
254    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    /// Group coverage data by source file
287    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}