Skip to main content

sbom_tools/reports/
html.rs

1//! HTML report generator.
2
3use super::escape::{escape_html, escape_html_opt};
4use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
5use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
6use crate::model::NormalizedSbom;
7use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
8use std::fmt::Write;
9
10/// HTML report generator
11pub struct HtmlReporter {
12    /// Include inline CSS
13    include_styles: bool,
14}
15
16impl HtmlReporter {
17    /// Create a new HTML reporter
18    #[must_use]
19    pub const fn new() -> Self {
20        Self {
21            include_styles: true,
22        }
23    }
24}
25
26impl Default for HtmlReporter {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32// ============================================================================
33// HTML building helpers
34// ============================================================================
35
36/// Write the HTML document head (doctype through opening body/container).
37fn write_html_head(html: &mut String, title: &str, include_styles: bool) -> std::fmt::Result {
38    writeln!(html, "<!DOCTYPE html>")?;
39    writeln!(html, "<html lang=\"en\">")?;
40    writeln!(html, "<head>")?;
41    writeln!(html, "    <meta charset=\"UTF-8\">")?;
42    writeln!(
43        html,
44        "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
45    )?;
46    writeln!(html, "    <title>{}</title>", escape_html(title))?;
47    if include_styles {
48        writeln!(html, "{HTML_STYLES}")?;
49    }
50    writeln!(html, "</head>")?;
51    writeln!(html, "<body>")?;
52    writeln!(html, "<div class=\"container\">")
53}
54
55/// Write the page header with title and generation info.
56fn write_page_header(html: &mut String, title: &str, subtitle: Option<&str>) -> std::fmt::Result {
57    writeln!(html, "<div class=\"header\" id=\"top\">")?;
58    writeln!(html, "    <h1>{}</h1>", escape_html(title))?;
59    if let Some(sub) = subtitle {
60        writeln!(html, "    <p>{}</p>", escape_html(sub))?;
61    }
62    writeln!(
63        html,
64        "    <p>Generated by sbom-tools v{} on {}</p>",
65        env!("CARGO_PKG_VERSION"),
66        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
67    )?;
68    writeln!(html, "</div>")
69}
70
71/// Write a table of contents navigation.
72fn write_toc(html: &mut String, sections: &[(&str, &str)]) -> std::fmt::Result {
73    writeln!(html, "<nav class=\"toc\">")?;
74    writeln!(html, "    <strong>Contents:</strong>")?;
75    for (id, label) in sections {
76        write!(html, "    <a href=\"#{id}\">{label}</a>")?;
77    }
78    writeln!(html)?;
79    writeln!(html, "</nav>")
80}
81
82/// Write a single summary card.
83fn write_card(html: &mut String, title: &str, value: &str, css_class: &str) -> std::fmt::Result {
84    writeln!(html, "    <div class=\"card\">")?;
85    writeln!(html, "        <div class=\"card-title\">{title}</div>")?;
86    writeln!(
87        html,
88        "        <div class=\"card-value {css_class}\">{value}</div>"
89    )?;
90    writeln!(html, "    </div>")
91}
92
93/// Write the HTML document footer and closing tags.
94fn write_html_footer(html: &mut String) -> std::fmt::Result {
95    writeln!(html, "<div class=\"footer\">")?;
96    writeln!(
97        html,
98        "    <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>"
99    )?;
100    writeln!(html, "</div>")?;
101    writeln!(html, "</div>")?;
102    writeln!(html, "</body>")?;
103    writeln!(html, "</html>")
104}
105
106/// Write an EOL components section if any components have EOL data.
107fn write_eol_section(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
108    use crate::model::EolStatus;
109
110    let eol_components: Vec<_> = sbom
111        .components
112        .values()
113        .filter(|c| {
114            c.eol.as_ref().is_some_and(|e| {
115                matches!(
116                    e.status,
117                    EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
118                )
119            })
120        })
121        .collect();
122
123    if eol_components.is_empty() {
124        return Ok(());
125    }
126
127    writeln!(html, "<div class=\"section\" id=\"eol\">")?;
128    writeln!(html, "    <h2>End-of-Life Components</h2>")?;
129    writeln!(html, "    <table>")?;
130    writeln!(html, "        <thead>")?;
131    writeln!(
132        html,
133        "            <tr><th>Component</th><th>Version</th><th>Status</th><th>Product</th><th>EOL Date</th></tr>"
134    )?;
135    writeln!(html, "        </thead>")?;
136    writeln!(html, "        <tbody>")?;
137
138    for comp in &eol_components {
139        let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
140        let badge_class = match eol.status {
141            EolStatus::EndOfLife => "badge-critical",
142            EolStatus::ApproachingEol => "badge-warning",
143            EolStatus::SecurityOnly => "badge-info",
144            _ => "",
145        };
146        let eol_date = eol
147            .eol_date
148            .map_or_else(|| "-".to_string(), |d| d.to_string());
149
150        writeln!(
151            html,
152            "            <tr><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td><td>{}</td><td>{}</td></tr>",
153            escape_html(&comp.name),
154            escape_html(comp.version.as_deref().unwrap_or("-")),
155            badge_class,
156            escape_html(eol.status.label()),
157            escape_html(&eol.product),
158            escape_html(&eol_date),
159        )?;
160    }
161
162    writeln!(html, "        </tbody>")?;
163    writeln!(html, "    </table>")?;
164    writeln!(html, "</div>")
165}
166
167/// Write the component changes table for diff reports.
168fn write_diff_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
169    writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
170    writeln!(html, "    <h2>Component Changes</h2>")?;
171    writeln!(html, "    <table>")?;
172    writeln!(html, "        <thead>")?;
173    writeln!(html, "            <tr>")?;
174    writeln!(html, "                <th>Status</th>")?;
175    writeln!(html, "                <th>Name</th>")?;
176    writeln!(html, "                <th>Old Version</th>")?;
177    writeln!(html, "                <th>New Version</th>")?;
178    writeln!(html, "                <th>Ecosystem</th>")?;
179    writeln!(html, "            </tr>")?;
180    writeln!(html, "        </thead>")?;
181    writeln!(html, "        <tbody>")?;
182
183    for comp in &result.components.added {
184        writeln!(html, "            <tr>")?;
185        writeln!(
186            html,
187            "                <td><span class=\"badge badge-added\">Added</span></td>"
188        )?;
189        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
190        writeln!(html, "                <td>-</td>")?;
191        writeln!(
192            html,
193            "                <td>{}</td>",
194            escape_html_opt(comp.new_version.as_deref())
195        )?;
196        writeln!(
197            html,
198            "                <td>{}</td>",
199            escape_html_opt(comp.ecosystem.as_deref())
200        )?;
201        writeln!(html, "            </tr>")?;
202    }
203
204    for comp in &result.components.removed {
205        writeln!(html, "            <tr>")?;
206        writeln!(
207            html,
208            "                <td><span class=\"badge badge-removed\">Removed</span></td>"
209        )?;
210        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
211        writeln!(
212            html,
213            "                <td>{}</td>",
214            escape_html_opt(comp.old_version.as_deref())
215        )?;
216        writeln!(html, "                <td>-</td>")?;
217        writeln!(
218            html,
219            "                <td>{}</td>",
220            escape_html_opt(comp.ecosystem.as_deref())
221        )?;
222        writeln!(html, "            </tr>")?;
223    }
224
225    for comp in &result.components.modified {
226        writeln!(html, "            <tr>")?;
227        writeln!(
228            html,
229            "                <td><span class=\"badge badge-modified\">Modified</span></td>"
230        )?;
231        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
232        writeln!(
233            html,
234            "                <td>{}</td>",
235            escape_html_opt(comp.old_version.as_deref())
236        )?;
237        writeln!(
238            html,
239            "                <td>{}</td>",
240            escape_html_opt(comp.new_version.as_deref())
241        )?;
242        writeln!(
243            html,
244            "                <td>{}</td>",
245            escape_html_opt(comp.ecosystem.as_deref())
246        )?;
247        writeln!(html, "            </tr>")?;
248    }
249
250    writeln!(html, "        </tbody>")?;
251    writeln!(html, "    </table>")?;
252    writeln!(html, "</div>")
253}
254
255/// Write the introduced vulnerabilities table for diff reports.
256fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
257    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
258    writeln!(html, "    <h2>Introduced Vulnerabilities</h2>")?;
259    writeln!(html, "    <table>")?;
260    writeln!(html, "        <thead>")?;
261    writeln!(html, "            <tr>")?;
262    writeln!(html, "                <th>ID</th>")?;
263    writeln!(html, "                <th>Severity</th>")?;
264    writeln!(html, "                <th>CVSS</th>")?;
265    writeln!(html, "                <th>SLA</th>")?;
266    writeln!(html, "                <th>Type</th>")?;
267    writeln!(html, "                <th>Component</th>")?;
268    writeln!(html, "                <th>Version</th>")?;
269    writeln!(html, "                <th>VEX</th>")?;
270    writeln!(html, "            </tr>")?;
271    writeln!(html, "        </thead>")?;
272    writeln!(html, "        <tbody>")?;
273
274    for vuln in &result.vulnerabilities.introduced {
275        let badge_class = match vuln.severity.to_lowercase().as_str() {
276            "critical" => "badge-critical",
277            "high" => "badge-high",
278            "medium" => "badge-medium",
279            _ => "badge-low",
280        };
281        let (depth_label, depth_class) = match vuln.component_depth {
282            Some(1) => ("Direct", "badge-direct"),
283            Some(_) => ("Transitive", "badge-transitive"),
284            None => ("-", ""),
285        };
286        writeln!(html, "            <tr>")?;
287        writeln!(html, "                <td>{}</td>", escape_html(&vuln.id))?;
288        writeln!(
289            html,
290            "                <td><span class=\"badge {}\">{}</span></td>",
291            badge_class,
292            escape_html(&vuln.severity)
293        )?;
294        writeln!(
295            html,
296            "                <td>{}</td>",
297            vuln.cvss_score
298                .map(|s| format!("{s:.1}"))
299                .as_deref()
300                .unwrap_or("-")
301        )?;
302        // SLA cell
303        let (sla_text, sla_class) = format_sla_html(vuln);
304        if sla_class.is_empty() {
305            writeln!(html, "                <td>{sla_text}</td>")?;
306        } else {
307            writeln!(
308                html,
309                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
310            )?;
311        }
312        if depth_class.is_empty() {
313            writeln!(html, "                <td>{depth_label}</td>")?;
314        } else {
315            writeln!(
316                html,
317                "                <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
318            )?;
319        }
320        writeln!(
321            html,
322            "                <td>{}</td>",
323            escape_html(&vuln.component_name)
324        )?;
325        writeln!(
326            html,
327            "                <td>{}</td>",
328            escape_html_opt(vuln.version.as_deref())
329        )?;
330        // VEX status
331        let vex_display = format_vex_html(vuln.vex_state.as_ref());
332        writeln!(html, "                <td>{vex_display}</td>")?;
333        writeln!(html, "            </tr>")?;
334    }
335
336    writeln!(html, "        </tbody>")?;
337    writeln!(html, "    </table>")?;
338    writeln!(html, "</div>")
339}
340
341/// Write the components table for view reports.
342fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
343    writeln!(html, "<div class=\"section\" id=\"components\">")?;
344    writeln!(html, "    <h2>Components</h2>")?;
345    writeln!(html, "    <table>")?;
346    writeln!(html, "        <thead>")?;
347    writeln!(html, "            <tr>")?;
348    writeln!(html, "                <th>Name</th>")?;
349    writeln!(html, "                <th>Version</th>")?;
350    writeln!(html, "                <th>Ecosystem</th>")?;
351    writeln!(html, "                <th>License</th>")?;
352    writeln!(html, "                <th>Vulnerabilities</th>")?;
353    writeln!(html, "            </tr>")?;
354    writeln!(html, "        </thead>")?;
355    writeln!(html, "        <tbody>")?;
356
357    // Sort components by name for consistent output
358    let mut components: Vec<_> = sbom.components.values().collect();
359    components.sort_by(|a, b| a.name.cmp(&b.name));
360
361    for comp in components {
362        let license_str = comp
363            .licenses
364            .declared
365            .first()
366            .map_or("-", |l| l.expression.as_str());
367        let vuln_count = comp.vulnerabilities.len();
368        let vuln_badge = if vuln_count > 0 {
369            format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
370        } else {
371            "0".to_string()
372        };
373
374        writeln!(html, "            <tr>")?;
375        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
376        writeln!(
377            html,
378            "                <td>{}</td>",
379            escape_html_opt(comp.version.as_deref())
380        )?;
381        writeln!(
382            html,
383            "                <td>{}</td>",
384            comp.ecosystem
385                .as_ref()
386                .map(|e| escape_html(&format!("{e:?}")))
387                .as_deref()
388                .unwrap_or("-")
389        )?;
390        writeln!(
391            html,
392            "                <td>{}</td>",
393            escape_html(license_str)
394        )?;
395        writeln!(html, "                <td>{vuln_badge}</td>")?;
396        writeln!(html, "            </tr>")?;
397    }
398
399    writeln!(html, "        </tbody>")?;
400    writeln!(html, "    </table>")?;
401    writeln!(html, "</div>")
402}
403
404/// A view vulnerability row with SLA info.
405type ViewVulnRow<'a> = (
406    &'a str,
407    &'a Option<crate::model::Severity>,
408    Option<f32>,
409    &'a str,
410    Option<&'a str>,
411    Option<&'a crate::model::VulnerabilityRef>,
412);
413
414/// Write the vulnerabilities table for view reports (with SLA columns).
415fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
416    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
417    writeln!(html, "    <h2>Vulnerabilities</h2>")?;
418    writeln!(html, "    <table>")?;
419    writeln!(html, "        <thead>")?;
420    writeln!(html, "            <tr>")?;
421    writeln!(html, "                <th>ID</th>")?;
422    writeln!(html, "                <th>Severity</th>")?;
423    writeln!(html, "                <th>CVSS</th>")?;
424    writeln!(html, "                <th>SLA</th>")?;
425    writeln!(html, "                <th>Component</th>")?;
426    writeln!(html, "                <th>Version</th>")?;
427    writeln!(html, "                <th>VEX</th>")?;
428    writeln!(html, "            </tr>")?;
429    writeln!(html, "        </thead>")?;
430    writeln!(html, "        <tbody>")?;
431
432    // Collect all vulnerabilities with their component info
433    let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
434        .components
435        .values()
436        .flat_map(|comp| {
437            comp.vulnerabilities.iter().map(move |v| {
438                (
439                    v.id.as_str(),
440                    &v.severity,
441                    v.cvss.first().map(|c| c.base_score),
442                    comp.name.as_str(),
443                    comp.version.as_deref(),
444                    Some(v),
445                )
446            })
447        })
448        .collect();
449
450    // Sort by severity (critical first)
451    all_vulns.sort_by(|a, b| {
452        let sev_order = |s: &Option<crate::model::Severity>| match s {
453            Some(crate::model::Severity::Critical) => 0,
454            Some(crate::model::Severity::High) => 1,
455            Some(crate::model::Severity::Medium) => 2,
456            Some(crate::model::Severity::Low) => 3,
457            Some(crate::model::Severity::Info) => 4,
458            _ => 5,
459        };
460        sev_order(a.1).cmp(&sev_order(b.1))
461    });
462
463    for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
464        let (badge_class, sev_str) = match severity {
465            Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
466            Some(crate::model::Severity::High) => ("badge-high", "High"),
467            Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
468            Some(crate::model::Severity::Low) => ("badge-low", "Low"),
469            Some(crate::model::Severity::Info) => ("badge-low", "Info"),
470            _ => ("badge-low", "Unknown"),
471        };
472
473        // Compute SLA from published date if available
474        let (sla_text, sla_class) = if let Some(v) = vuln {
475            compute_view_sla(v)
476        } else {
477            ("-".to_string(), "sla-unknown")
478        };
479
480        writeln!(html, "            <tr>")?;
481        writeln!(html, "                <td>{}</td>", escape_html(id))?;
482        writeln!(
483            html,
484            "                <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
485        )?;
486        writeln!(
487            html,
488            "                <td>{}</td>",
489            cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
490        )?;
491        if sla_class.is_empty() {
492            writeln!(html, "                <td>{sla_text}</td>")?;
493        } else {
494            writeln!(
495                html,
496                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
497            )?;
498        }
499        writeln!(html, "                <td>{}</td>", escape_html(comp_name))?;
500        writeln!(
501            html,
502            "                <td>{}</td>",
503            escape_html_opt(version)
504        )?;
505        // VEX status from per-vuln
506        let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
507        let vex_display = format_vex_html(vex_state);
508        writeln!(html, "                <td>{vex_display}</td>")?;
509        writeln!(html, "            </tr>")?;
510    }
511
512    writeln!(html, "        </tbody>")?;
513    writeln!(html, "    </table>")?;
514    writeln!(html, "</div>")
515}
516
517/// Compute SLA display for a vulnerability in view mode (from published date).
518fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
519    if let Some(published) = vuln.published {
520        let delta: chrono::TimeDelta = chrono::Utc::now() - published;
521        let days = delta.num_days();
522        if days < 0 {
523            return ("-".to_string(), "sla-unknown");
524        }
525        let days = days as u64;
526        // Use standard SLA thresholds based on severity
527        let sla_days: Option<u64> = match &vuln.severity {
528            Some(crate::model::Severity::Critical) => Some(15),
529            Some(crate::model::Severity::High) => Some(30),
530            Some(crate::model::Severity::Medium) => Some(90),
531            Some(crate::model::Severity::Low) => Some(180),
532            _ => None,
533        };
534        if let Some(sla) = sla_days {
535            if days > sla {
536                (format!("{}d late", days - sla), "sla-overdue")
537            } else if sla - days <= 7 {
538                (format!("{}d left", sla - days), "sla-due-soon")
539            } else {
540                (format!("{}d left", sla - days), "sla-on-track")
541            }
542        } else {
543            (format!("{days}d old"), "sla-unknown")
544        }
545    } else {
546        ("-".to_string(), "sla-unknown")
547    }
548}
549
550/// Format SLA status for HTML display.
551fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
552    match vuln.sla_status() {
553        SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
554        SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
555        SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
556        SlaStatus::NoDueDate => {
557            let text = vuln
558                .days_since_published
559                .map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
560            (text, "sla-unknown")
561        }
562    }
563}
564
565/// Format VEX state as an HTML badge string.
566fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
567    match vex_state {
568        Some(crate::model::VexState::NotAffected) => {
569            "<span class=\"badge badge-added\">Not Affected</span>".to_string()
570        }
571        Some(crate::model::VexState::Fixed) => {
572            "<span class=\"badge badge-added\">Fixed</span>".to_string()
573        }
574        Some(crate::model::VexState::Affected) => {
575            "<span class=\"badge badge-removed\">Affected</span>".to_string()
576        }
577        Some(crate::model::VexState::UnderInvestigation) => {
578            "<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
579        }
580        None => "-".to_string(),
581    }
582}
583
584/// Compute compliance score as percentage (0-100)
585fn compliance_score_html(result: &ComplianceResult) -> u8 {
586    let total = result.violations.len() + 1;
587    let issues = result.error_count + result.warning_count;
588    let score = if issues >= total {
589        0
590    } else {
591        ((total - issues) * 100) / total
592    };
593    score.min(100) as u8
594}
595
596/// Generate an HTML trend badge for numeric delta
597fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
598    if old_val == new_val {
599        ""
600    } else if (new_val < old_val) == lower_is_better {
601        " <span class=\"badge badge-added\">improved</span>"
602    } else {
603        " <span class=\"badge badge-removed\">regressed</span>"
604    }
605}
606
607/// Write a CRA compliance comparison section for diff reports.
608fn write_cra_compliance_diff_html(
609    html: &mut String,
610    old: &ComplianceResult,
611    new: &ComplianceResult,
612) -> std::fmt::Result {
613    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
614    writeln!(html, "    <h2>CRA Compliance</h2>")?;
615    writeln!(html, "    <table>")?;
616    writeln!(html, "        <thead>")?;
617    writeln!(
618        html,
619        "            <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
620    )?;
621    writeln!(html, "        </thead>")?;
622    writeln!(html, "        <tbody>")?;
623
624    let old_badge = compliance_status_badge(old.is_compliant);
625    let new_badge = compliance_status_badge(new.is_compliant);
626    let old_score = compliance_score_html(old);
627    let new_score = compliance_score_html(new);
628    let err_trend = trend_badge(old.error_count, new.error_count, true);
629    let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
630    let score_trend = trend_badge(old_score.into(), new_score.into(), false);
631
632    writeln!(
633        html,
634        "            <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
635    )?;
636    writeln!(
637        html,
638        "            <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
639    )?;
640    writeln!(
641        html,
642        "            <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
643        escape_html(old.level.name()),
644        escape_html(new.level.name())
645    )?;
646    writeln!(
647        html,
648        "            <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
649        old.error_count, new.error_count
650    )?;
651    writeln!(
652        html,
653        "            <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
654        old.warning_count, new.warning_count
655    )?;
656
657    writeln!(html, "        </tbody>")?;
658    writeln!(html, "    </table>")?;
659
660    if !new.violations.is_empty() {
661        writeln!(html, "    <h3>Violations (New SBOM)</h3>")?;
662        write_violation_table_html(html, &new.violations)?;
663    }
664
665    writeln!(html, "</div>")?;
666    writeln!(
667        html,
668        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
669    )
670}
671
672/// Write a CRA compliance section for view reports.
673fn write_cra_compliance_view_html(
674    html: &mut String,
675    result: &ComplianceResult,
676) -> std::fmt::Result {
677    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
678    writeln!(html, "    <h2>CRA Compliance</h2>")?;
679
680    let badge = compliance_status_badge(result.is_compliant);
681    let score = compliance_score_html(result);
682    writeln!(html, "    <p><strong>Status:</strong> {badge} &nbsp; ")?;
683    writeln!(html, "    <strong>Score:</strong> {score}% &nbsp; ")?;
684    writeln!(
685        html,
686        "    <strong>Level:</strong> {} &nbsp; ",
687        escape_html(result.level.name())
688    )?;
689    writeln!(
690        html,
691        "    <strong>Issues:</strong> {} errors, {} warnings</p>",
692        result.error_count, result.warning_count
693    )?;
694
695    if !result.violations.is_empty() {
696        write_violation_table_html(html, &result.violations)?;
697    }
698
699    writeln!(html, "</div>")?;
700    writeln!(
701        html,
702        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
703    )
704}
705
706/// Aggregate violations by (severity, category, requirement) to reduce noise.
707fn aggregate_violations_html(
708    violations: &[crate::quality::Violation],
709) -> Vec<AggregatedViolationHtml<'_>> {
710    use std::collections::BTreeMap;
711
712    let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
713    for v in violations {
714        let sev_ord = match v.severity {
715            ViolationSeverity::Error => 0,
716            ViolationSeverity::Warning => 1,
717            ViolationSeverity::Info => 2,
718        };
719        groups
720            .entry((sev_ord, v.category.name(), v.requirement.as_str()))
721            .or_default()
722            .push(v);
723    }
724
725    groups
726        .into_values()
727        .map(|group| {
728            let message = if group.len() == 1 {
729                group[0].message.clone()
730            } else {
731                let elements: Vec<&str> =
732                    group.iter().filter_map(|v| v.element.as_deref()).collect();
733                if elements.is_empty() {
734                    group[0].message.clone()
735                } else {
736                    let preview: Vec<&str> = elements.iter().take(5).copied().collect();
737                    let suffix = if elements.len() > 5 {
738                        format!(", ... +{} more", elements.len() - 5)
739                    } else {
740                        String::new()
741                    };
742                    format!(
743                        "{} components affected ({}{})",
744                        elements.len(),
745                        preview.join(", "),
746                        suffix
747                    )
748                }
749            };
750            AggregatedViolationHtml {
751                severity: group[0].severity,
752                category: group[0].category.name(),
753                requirement: &group[0].requirement,
754                message,
755                remediation: group[0].remediation_guidance(),
756                count: group.len(),
757            }
758        })
759        .collect()
760}
761
762struct AggregatedViolationHtml<'a> {
763    severity: ViolationSeverity,
764    category: &'a str,
765    requirement: &'a str,
766    message: String,
767    remediation: &'static str,
768    count: usize,
769}
770
771/// Write an HTML table of compliance violations (aggregated, with collapsible remediation).
772fn write_violation_table_html(
773    html: &mut String,
774    violations: &[crate::quality::Violation],
775) -> std::fmt::Result {
776    let aggregated = aggregate_violations_html(violations);
777    writeln!(html, "    <table>")?;
778    writeln!(html, "        <thead>")?;
779    writeln!(html, "            <tr>")?;
780    writeln!(html, "                <th>Severity</th>")?;
781    writeln!(html, "                <th>Category</th>")?;
782    writeln!(html, "                <th>Requirement</th>")?;
783    writeln!(html, "                <th>Message</th>")?;
784    writeln!(html, "                <th>Remediation</th>")?;
785    writeln!(html, "            </tr>")?;
786    writeln!(html, "        </thead>")?;
787    writeln!(html, "        <tbody>")?;
788
789    for v in &aggregated {
790        let (badge_class, label) = match v.severity {
791            ViolationSeverity::Error => ("badge-critical", "Error"),
792            ViolationSeverity::Warning => ("badge-medium", "Warning"),
793            ViolationSeverity::Info => ("badge-low", "Info"),
794        };
795        let count_suffix = if v.count > 1 {
796            format!(
797                " <span class=\"badge badge-transitive\">x{}</span>",
798                v.count
799            )
800        } else {
801            String::new()
802        };
803        writeln!(html, "            <tr>")?;
804        writeln!(
805            html,
806            "                <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
807        )?;
808        writeln!(html, "                <td>{}</td>", escape_html(v.category))?;
809        writeln!(
810            html,
811            "                <td>{}</td>",
812            escape_html(v.requirement)
813        )?;
814        writeln!(html, "                <td>{}</td>", escape_html(&v.message))?;
815        writeln!(
816            html,
817            "                <td><details><summary>View</summary>{}</details></td>",
818            escape_html(v.remediation)
819        )?;
820        writeln!(html, "            </tr>")?;
821    }
822
823    writeln!(html, "        </tbody>")?;
824    writeln!(html, "    </table>")
825}
826
827/// Generate an HTML badge for compliance status.
828fn compliance_status_badge(is_compliant: bool) -> &'static str {
829    if is_compliant {
830        "<span class=\"badge badge-added\">Compliant</span>"
831    } else {
832        "<span class=\"badge badge-removed\">Non-compliant</span>"
833    }
834}
835
836// ============================================================================
837// Report generation (using helpers above)
838// ============================================================================
839
840impl ReportGenerator for HtmlReporter {
841    fn generate_diff_report(
842        &self,
843        result: &DiffResult,
844        old_sbom: &NormalizedSbom,
845        new_sbom: &NormalizedSbom,
846        config: &ReportConfig,
847    ) -> Result<String, ReportError> {
848        let mut html = String::new();
849        let title = config
850            .title
851            .clone()
852            .unwrap_or_else(|| "SBOM Diff Report".to_string());
853
854        write_html_head(&mut html, &title, self.include_styles)?;
855        write_page_header(&mut html, &title, None)?;
856
857        // Build TOC entries based on what will render
858        let has_components =
859            config.includes(ReportType::Components) && !result.components.is_empty();
860        let has_vulns = config.includes(ReportType::Vulnerabilities)
861            && !result.vulnerabilities.introduced.is_empty();
862        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
863        if has_components {
864            toc_entries.push(("component-changes", "Components"));
865        }
866        if has_vulns {
867            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
868        }
869        toc_entries.push(("cra-compliance", "CRA Compliance"));
870        write_toc(&mut html, &toc_entries)?;
871
872        // Summary cards
873        writeln!(html, "<div class=\"summary-cards\">")?;
874        write_card(
875            &mut html,
876            "Components Added",
877            &format!("+{}", result.summary.components_added),
878            "added",
879        )?;
880        write_card(
881            &mut html,
882            "Components Removed",
883            &format!("-{}", result.summary.components_removed),
884            "removed",
885        )?;
886        write_card(
887            &mut html,
888            "Components Modified",
889            &format!("~{}", result.summary.components_modified),
890            "modified",
891        )?;
892        write_card(
893            &mut html,
894            "Vulns Introduced",
895            &result.summary.vulnerabilities_introduced.to_string(),
896            "critical",
897        )?;
898        write_card(
899            &mut html,
900            "Semantic Score",
901            &format!("{:.1}", result.semantic_score),
902            "",
903        )?;
904        writeln!(html, "</div>")?;
905
906        // Component changes
907        if has_components {
908            write_diff_component_table(&mut html, result)?;
909        }
910
911        // Vulnerability changes
912        if has_vulns {
913            write_diff_vuln_table(&mut html, result)?;
914        }
915
916        // End-of-Life section
917        write_eol_section(&mut html, new_sbom)?;
918
919        // CRA Compliance
920        {
921            let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
922                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
923            });
924            let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
925                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
926            });
927            write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
928        }
929
930        write_html_footer(&mut html)?;
931        Ok(html)
932    }
933
934    fn generate_view_report(
935        &self,
936        sbom: &NormalizedSbom,
937        config: &ReportConfig,
938    ) -> Result<String, ReportError> {
939        use std::collections::HashSet;
940
941        let mut html = String::new();
942        let title = config
943            .title
944            .clone()
945            .unwrap_or_else(|| "SBOM Report".to_string());
946
947        // Compute statistics
948        let total_components = sbom.component_count();
949        let vuln_component_count = sbom
950            .components
951            .values()
952            .filter(|c| !c.vulnerabilities.is_empty())
953            .count();
954        let total_vulns: usize = sbom
955            .components
956            .values()
957            .map(|c| c.vulnerabilities.len())
958            .sum();
959        let ecosystems: HashSet<_> = sbom
960            .components
961            .values()
962            .filter_map(|c| c.ecosystem.as_ref())
963            .collect();
964        let licenses: HashSet<String> = sbom
965            .components
966            .values()
967            .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
968            .collect();
969
970        let subtitle = sbom
971            .document
972            .name
973            .as_deref()
974            .map(|n| format!("Document: {n}"));
975        write_html_head(&mut html, &title, self.include_styles)?;
976        write_page_header(&mut html, &title, subtitle.as_deref())?;
977
978        // Build TOC
979        let has_components = config.includes(ReportType::Components) && total_components > 0;
980        let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
981        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
982        if has_components {
983            toc_entries.push(("components", "Components"));
984        }
985        if has_vulns {
986            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
987        }
988        toc_entries.push(("cra-compliance", "CRA Compliance"));
989        write_toc(&mut html, &toc_entries)?;
990
991        // Summary cards
992        writeln!(html, "<div class=\"summary-cards\">")?;
993        write_card(
994            &mut html,
995            "Total Components",
996            &total_components.to_string(),
997            "",
998        )?;
999        let vuln_class = if vuln_component_count > 0 {
1000            "critical"
1001        } else {
1002            ""
1003        };
1004        write_card(
1005            &mut html,
1006            "Vulnerable Components",
1007            &vuln_component_count.to_string(),
1008            vuln_class,
1009        )?;
1010        let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
1011        write_card(
1012            &mut html,
1013            "Total Vulnerabilities",
1014            &total_vulns.to_string(),
1015            total_vuln_class,
1016        )?;
1017        write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
1018        write_card(
1019            &mut html,
1020            "Unique Licenses",
1021            &licenses.len().to_string(),
1022            "",
1023        )?;
1024        writeln!(html, "</div>")?;
1025
1026        // Components table
1027        if has_components {
1028            write_view_component_table(&mut html, sbom)?;
1029        }
1030
1031        // Vulnerabilities table
1032        if has_vulns {
1033            write_view_vuln_table(&mut html, sbom)?;
1034        }
1035
1036        // CRA Compliance
1037        {
1038            let cra = config
1039                .view_cra_compliance
1040                .clone()
1041                .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
1042            write_cra_compliance_view_html(&mut html, &cra)?;
1043        }
1044
1045        write_html_footer(&mut html)?;
1046        Ok(html)
1047    }
1048
1049    fn format(&self) -> ReportFormat {
1050        ReportFormat::Html
1051    }
1052}
1053
1054// ============================================================================
1055// Inline CSS styles
1056// ============================================================================
1057
1058const HTML_STYLES: &str = r"
1059        <style>
1060            :root {
1061                --bg-color: #1e1e2e;
1062                --text-color: #cdd6f4;
1063                --accent-color: #89b4fa;
1064                --success-color: #a6e3a1;
1065                --warning-color: #f9e2af;
1066                --error-color: #f38ba8;
1067                --border-color: #45475a;
1068                --card-bg: #313244;
1069            }
1070
1071            body {
1072                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1073                background-color: var(--bg-color);
1074                color: var(--text-color);
1075                margin: 0;
1076                padding: 20px;
1077                line-height: 1.6;
1078            }
1079
1080            .container {
1081                max-width: 1200px;
1082                margin: 0 auto;
1083            }
1084
1085            h1, h2, h3 {
1086                color: var(--accent-color);
1087            }
1088
1089            .header {
1090                border-bottom: 2px solid var(--border-color);
1091                padding-bottom: 20px;
1092                margin-bottom: 30px;
1093            }
1094
1095            .summary-cards {
1096                display: grid;
1097                grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1098                gap: 20px;
1099                margin-bottom: 30px;
1100            }
1101
1102            .card {
1103                background-color: var(--card-bg);
1104                border-radius: 8px;
1105                padding: 20px;
1106                border: 1px solid var(--border-color);
1107            }
1108
1109            .card-title {
1110                font-size: 0.9em;
1111                color: #a6adc8;
1112                margin-bottom: 10px;
1113            }
1114
1115            .card-value {
1116                font-size: 2em;
1117                font-weight: bold;
1118            }
1119
1120            .card-value.added { color: var(--success-color); }
1121            .card-value.removed { color: var(--error-color); }
1122            .card-value.modified { color: var(--warning-color); }
1123            .card-value.critical { color: var(--error-color); }
1124
1125            table {
1126                width: 100%;
1127                border-collapse: collapse;
1128                margin-bottom: 30px;
1129                background-color: var(--card-bg);
1130                border-radius: 8px;
1131                overflow: hidden;
1132            }
1133
1134            th, td {
1135                padding: 12px 15px;
1136                text-align: left;
1137                border-bottom: 1px solid var(--border-color);
1138            }
1139
1140            th {
1141                background-color: #45475a;
1142                font-weight: 600;
1143            }
1144
1145            tr:hover {
1146                background-color: #3b3d4d;
1147            }
1148
1149            .badge {
1150                display: inline-block;
1151                padding: 2px 8px;
1152                border-radius: 4px;
1153                font-size: 0.85em;
1154                font-weight: 500;
1155            }
1156
1157            .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
1158            .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
1159            .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
1160            .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
1161            .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
1162            .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
1163            .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
1164            .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
1165            .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
1166            .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
1167            .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
1168            .sla-on-track { color: #8b949e; }
1169            .sla-unknown { color: #8b949e; }
1170
1171            .section {
1172                margin-bottom: 40px;
1173            }
1174
1175            .tabs {
1176                display: flex;
1177                border-bottom: 2px solid var(--border-color);
1178                margin-bottom: 20px;
1179            }
1180
1181            .tab {
1182                padding: 10px 20px;
1183                cursor: pointer;
1184                border-bottom: 2px solid transparent;
1185                margin-bottom: -2px;
1186            }
1187
1188            .tab:hover {
1189                color: var(--accent-color);
1190            }
1191
1192            .tab.active {
1193                border-bottom-color: var(--accent-color);
1194                color: var(--accent-color);
1195            }
1196
1197            .footer {
1198                margin-top: 40px;
1199                padding-top: 20px;
1200                border-top: 1px solid var(--border-color);
1201                font-size: 0.9em;
1202                color: #a6adc8;
1203            }
1204
1205            .toc {
1206                background-color: var(--card-bg);
1207                border: 1px solid var(--border-color);
1208                border-radius: 8px;
1209                padding: 12px 20px;
1210                margin-bottom: 30px;
1211                display: flex;
1212                gap: 16px;
1213                align-items: center;
1214                flex-wrap: wrap;
1215            }
1216
1217            .toc a {
1218                color: var(--accent-color);
1219                text-decoration: none;
1220                padding: 4px 8px;
1221                border-radius: 4px;
1222            }
1223
1224            .toc a:hover {
1225                background-color: rgba(137, 180, 250, 0.1);
1226            }
1227
1228            .back-to-top {
1229                display: inline-block;
1230                color: #a6adc8;
1231                text-decoration: none;
1232                font-size: 0.85em;
1233                margin-bottom: 20px;
1234            }
1235
1236            .back-to-top:hover {
1237                color: var(--accent-color);
1238            }
1239
1240            details summary {
1241                cursor: pointer;
1242                color: var(--accent-color);
1243                font-size: 0.85em;
1244            }
1245
1246            details summary:hover {
1247                text-decoration: underline;
1248            }
1249
1250            details[open] summary {
1251                margin-bottom: 6px;
1252            }
1253        </style>
1254";