use super::escape::{
escape_markdown_inline, escape_markdown_list, escape_markdown_table, escape_md_opt,
};
use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
use crate::model::NormalizedSbom;
use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
use std::fmt::Write;
pub struct MarkdownReporter {
include_toc: bool,
}
impl MarkdownReporter {
#[must_use]
pub const fn new() -> Self {
Self { include_toc: true }
}
#[must_use]
pub const fn include_toc(mut self, include: bool) -> Self {
self.include_toc = include;
self
}
}
impl Default for MarkdownReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for MarkdownReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let mut md = String::new();
let title = config
.title
.clone()
.unwrap_or_else(|| "SBOM Diff Report".to_string());
writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
writeln!(
md,
"**Generated by:** sbom-tools v{}",
env!("CARGO_PKG_VERSION")
)?;
writeln!(
md,
"**Date:** {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
)?;
if self.include_toc {
writeln!(md, "## Table of Contents\n")?;
writeln!(md, "- [Summary](#summary)")?;
if config.includes(ReportType::Components) {
writeln!(md, "- [Component Changes](#component-changes)")?;
}
if config.includes(ReportType::Dependencies) {
writeln!(md, "- [Dependency Changes](#dependency-changes)")?;
}
if config.includes(ReportType::Licenses)
&& (!result.licenses.new_licenses.is_empty()
|| !result.licenses.removed_licenses.is_empty()
|| !result.licenses.conflicts.is_empty())
{
writeln!(md, "- [License Changes](#license-changes)")?;
}
if config.includes(ReportType::Vulnerabilities)
&& (!result.vulnerabilities.introduced.is_empty()
|| !result.vulnerabilities.resolved.is_empty())
{
writeln!(md, "- [Vulnerability Changes](#vulnerability-changes)")?;
}
if result
.graph_summary
.as_ref()
.is_some_and(|s| s.total_changes > 0)
{
writeln!(md, "- [Graph Changes](#graph-changes)")?;
}
writeln!(md, "- [CRA Compliance](#cra-compliance)")?;
writeln!(md)?;
}
writeln!(md, "## Summary\n")?;
writeln!(md, "| Metric | Old SBOM | New SBOM |")?;
writeln!(md, "|--------|----------|----------|")?;
writeln!(
md,
"| **Format** | {} | {} |",
old_sbom.document.format, new_sbom.document.format
)?;
writeln!(
md,
"| **Components** | {} | {} |",
old_sbom.component_count(),
new_sbom.component_count()
)?;
writeln!(
md,
"| **Dependencies** | {} | {} |",
old_sbom.edges.len(),
new_sbom.edges.len()
)?;
writeln!(md)?;
writeln!(md, "### Change Summary\n")?;
writeln!(md, "| Category | Count |")?;
writeln!(md, "|----------|-------|")?;
writeln!(
md,
"| Components Added | {} |",
result.summary.components_added
)?;
writeln!(
md,
"| Components Removed | {} |",
result.summary.components_removed
)?;
writeln!(
md,
"| Components Modified | {} |",
result.summary.components_modified
)?;
writeln!(
md,
"| Vulnerabilities Introduced | {} |",
result.summary.vulnerabilities_introduced
)?;
writeln!(
md,
"| Vulnerabilities Resolved | {} |",
result.summary.vulnerabilities_resolved
)?;
writeln!(md, "| **Semantic Score** | {:.1} |", result.semantic_score)?;
writeln!(md)?;
if config.includes(ReportType::Components) {
writeln!(md, "## Component Changes\n")?;
if !result.components.added.is_empty() {
writeln!(md, "### Added Components\n")?;
writeln!(md, "| Name | Version | Ecosystem |")?;
writeln!(md, "|------|---------|-----------|")?;
for comp in &result.components.added {
writeln!(
md,
"| {} | {} | {} |",
escape_markdown_table(&comp.name),
escape_md_opt(comp.new_version.as_deref()),
escape_md_opt(comp.ecosystem.as_deref())
)?;
}
writeln!(md)?;
}
if !result.components.removed.is_empty() {
writeln!(md, "### Removed Components\n")?;
writeln!(md, "| Name | Version | Ecosystem |")?;
writeln!(md, "|------|---------|-----------|")?;
for comp in &result.components.removed {
writeln!(
md,
"| {} | {} | {} |",
escape_markdown_table(&comp.name),
escape_md_opt(comp.old_version.as_deref()),
escape_md_opt(comp.ecosystem.as_deref())
)?;
}
writeln!(md)?;
}
if !result.components.modified.is_empty() {
writeln!(md, "### Modified Components\n")?;
writeln!(md, "| Name | Old Version | New Version | Changes |")?;
writeln!(md, "|------|-------------|-------------|---------|")?;
for comp in &result.components.modified {
let changes: Vec<String> = comp
.field_changes
.iter()
.map(|c| escape_markdown_table(&c.field))
.collect();
writeln!(
md,
"| {} | {} | {} | {} |",
escape_markdown_table(&comp.name),
escape_md_opt(comp.old_version.as_deref()),
escape_md_opt(comp.new_version.as_deref()),
changes.join(", ")
)?;
}
writeln!(md)?;
}
}
if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
writeln!(md, "## Dependency Changes\n")?;
if !result.dependencies.added.is_empty() {
writeln!(md, "### Added Dependencies\n")?;
writeln!(md, "| From | To | Relationship |")?;
writeln!(md, "|------|----|--------------|")?;
for dep in &result.dependencies.added {
writeln!(
md,
"| {} | {} | {} |",
escape_markdown_table(&dep.from),
escape_markdown_table(&dep.to),
escape_markdown_table(&dep.relationship)
)?;
}
writeln!(md)?;
}
if !result.dependencies.removed.is_empty() {
writeln!(md, "### Removed Dependencies\n")?;
writeln!(md, "| From | To | Relationship |")?;
writeln!(md, "|------|----|--------------|")?;
for dep in &result.dependencies.removed {
writeln!(
md,
"| {} | {} | {} |",
escape_markdown_table(&dep.from),
escape_markdown_table(&dep.to),
escape_markdown_table(&dep.relationship)
)?;
}
writeln!(md)?;
}
}
if config.includes(ReportType::Licenses)
&& (!result.licenses.new_licenses.is_empty()
|| !result.licenses.removed_licenses.is_empty()
|| !result.licenses.conflicts.is_empty())
{
writeln!(md, "## License Changes\n")?;
if !result.licenses.new_licenses.is_empty() {
writeln!(md, "### New Licenses\n")?;
for lic in &result.licenses.new_licenses {
let escaped_components: Vec<String> = lic
.components
.iter()
.map(|c| escape_markdown_list(c))
.collect();
writeln!(
md,
"- **{}**: {}",
escape_markdown_list(&lic.license),
escaped_components.join(", ")
)?;
}
writeln!(md)?;
}
if !result.licenses.removed_licenses.is_empty() {
writeln!(md, "### Removed Licenses\n")?;
for lic in &result.licenses.removed_licenses {
let escaped_components: Vec<String> = lic
.components
.iter()
.map(|c| escape_markdown_list(c))
.collect();
writeln!(
md,
"- **{}**: {}",
escape_markdown_list(&lic.license),
escaped_components.join(", ")
)?;
}
writeln!(md)?;
}
if !result.licenses.conflicts.is_empty() {
writeln!(md, "### License Conflicts\n")?;
writeln!(md, "| License A | License B | Component | Description |")?;
writeln!(md, "|-----------|-----------|-----------|-------------|")?;
for conflict in &result.licenses.conflicts {
writeln!(
md,
"| {} | {} | {} | {} |",
escape_markdown_table(&conflict.license_a),
escape_markdown_table(&conflict.license_b),
escape_markdown_table(&conflict.component),
escape_markdown_table(&conflict.description)
)?;
}
writeln!(md)?;
}
}
if config.includes(ReportType::Vulnerabilities)
&& (!result.vulnerabilities.introduced.is_empty()
|| !result.vulnerabilities.resolved.is_empty())
{
writeln!(md, "## Vulnerability Changes\n")?;
if !result.vulnerabilities.introduced.is_empty() {
writeln!(md, "### Introduced Vulnerabilities\n")?;
writeln!(
md,
"| ID | Severity | CVSS | SLA | Type | Component | Version | VEX |"
)?;
writeln!(
md,
"|----|----------|------|-----|------|-----------|---------|-----|"
)?;
for vuln in &result.vulnerabilities.introduced {
let depth_label = match vuln.component_depth {
Some(1) => "Direct",
Some(_) => "Transitive",
None => "-",
};
let sla_display = format_sla_display(vuln);
let vex_display = format_vex_display(vuln.vex_state.as_ref());
writeln!(
md,
"| {} | {} | {} | {} | {} | {} | {} | {} |",
escape_markdown_table(&vuln.id),
escape_markdown_table(&vuln.severity),
vuln.cvss_score
.map(|s| format!("{s:.1}"))
.as_deref()
.unwrap_or("-"),
escape_markdown_table(&sla_display),
depth_label,
escape_markdown_table(&vuln.component_name),
escape_md_opt(vuln.version.as_deref()),
vex_display,
)?;
}
writeln!(md)?;
}
if !result.vulnerabilities.resolved.is_empty() {
writeln!(md, "### Resolved Vulnerabilities\n")?;
writeln!(md, "| ID | Severity | SLA | Type | Component | VEX |")?;
writeln!(md, "|----|----------|-----|------|-----------|-----|")?;
for vuln in &result.vulnerabilities.resolved {
let depth_label = match vuln.component_depth {
Some(1) => "Direct",
Some(_) => "Transitive",
None => "-",
};
let sla_display = format_sla_display(vuln);
let vex_display = format_vex_display(vuln.vex_state.as_ref());
writeln!(
md,
"| {} | {} | {} | {} | {} | {} |",
escape_markdown_table(&vuln.id),
escape_markdown_table(&vuln.severity),
escape_markdown_table(&sla_display),
depth_label,
escape_markdown_table(&vuln.component_name),
vex_display,
)?;
}
writeln!(md)?;
}
}
{
let vex_summary = result.vulnerabilities.vex_summary();
if vex_summary.total_vulns > 0 {
writeln!(md, "### VEX Coverage\n")?;
writeln!(md, "| Metric | Value |")?;
writeln!(md, "|--------|-------|")?;
writeln!(
md,
"| Coverage | {:.1}% ({}/{}) |",
vex_summary.coverage_pct, vex_summary.with_vex, vex_summary.total_vulns
)?;
writeln!(md, "| Actionable | {} |", vex_summary.actionable)?;
for (state, count) in &vex_summary.by_state {
writeln!(md, "| {state} | {count} |")?;
}
writeln!(md)?;
}
}
if let Some(ref summary) = result.graph_summary
&& summary.total_changes > 0
{
writeln!(md, "## Graph Changes\n")?;
writeln!(md, "| Type | Count |")?;
writeln!(md, "|------|-------|")?;
writeln!(
md,
"| Dependencies Added | {} |",
summary.dependencies_added
)?;
writeln!(
md,
"| Dependencies Removed | {} |",
summary.dependencies_removed
)?;
writeln!(
md,
"| Relationship Changed | {} |",
summary.relationship_changed
)?;
writeln!(md, "| Reparented | {} |", summary.reparented)?;
writeln!(md, "| Depth Changed | {} |", summary.depth_changed)?;
writeln!(md, "| **Total** | **{}** |", summary.total_changes)?;
writeln!(md)?;
if !result.graph_changes.is_empty() {
writeln!(md, "### Graph Change Details\n")?;
writeln!(md, "| Impact | Type | Component | Details |")?;
writeln!(md, "|--------|------|-----------|---------|")?;
for change in &result.graph_changes {
let impact = change.impact.as_str().to_uppercase();
let (change_type, details) = match &change.change {
crate::diff::DependencyChangeType::DependencyAdded {
dependency_name,
..
} => ("Added", format!("+ {dependency_name}")),
crate::diff::DependencyChangeType::DependencyRemoved {
dependency_name,
..
} => ("Removed", format!("- {dependency_name}")),
crate::diff::DependencyChangeType::RelationshipChanged {
dependency_name,
old_relationship,
new_relationship,
..
} => (
"Rel Changed",
format!("{dependency_name}: {old_relationship} → {new_relationship}"),
),
crate::diff::DependencyChangeType::Reparented {
old_parent_name,
new_parent_name,
..
} => (
"Reparented",
format!("{old_parent_name} → {new_parent_name}"),
),
crate::diff::DependencyChangeType::DepthChanged {
old_depth,
new_depth,
} => {
let od = if *old_depth == u32::MAX {
"unreachable".to_string()
} else {
old_depth.to_string()
};
let nd = if *new_depth == u32::MAX {
"unreachable".to_string()
} else {
new_depth.to_string()
};
("Depth", format!("{od} → {nd}"))
}
};
writeln!(
md,
"| {} | {} | {} | {} |",
escape_markdown_table(&impact),
change_type,
escape_markdown_table(&change.component_name),
escape_markdown_table(&details),
)?;
}
writeln!(md)?;
}
if summary.by_impact.critical > 0 || summary.by_impact.high > 0 {
writeln!(md, "### Impact Summary\n")?;
writeln!(md, "| Impact | Count |")?;
writeln!(md, "|--------|-------|")?;
if summary.by_impact.critical > 0 {
writeln!(md, "| Critical | {} |", summary.by_impact.critical)?;
}
if summary.by_impact.high > 0 {
writeln!(md, "| High | {} |", summary.by_impact.high)?;
}
if summary.by_impact.medium > 0 {
writeln!(md, "| Medium | {} |", summary.by_impact.medium)?;
}
if summary.by_impact.low > 0 {
writeln!(md, "| Low | {} |", summary.by_impact.low)?;
}
writeln!(md)?;
}
}
{
let eol_components: Vec<_> = new_sbom
.components
.values()
.filter(|c| {
c.eol.as_ref().is_some_and(|e| {
matches!(
e.status,
crate::model::EolStatus::EndOfLife
| crate::model::EolStatus::ApproachingEol
)
})
})
.collect();
if !eol_components.is_empty() {
writeln!(md, "## End-of-Life Components\n")?;
writeln!(md, "| Component | Version | Status | Product | EOL Date |")?;
writeln!(md, "|-----------|---------|--------|---------|----------|")?;
for comp in &eol_components {
let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
writeln!(
md,
"| {} | {} | {} | {} | {} |",
escape_markdown_table(&comp.name),
escape_md_opt(comp.version.as_deref()),
escape_markdown_table(eol.status.label()),
escape_markdown_table(&eol.product),
eol.eol_date
.map_or_else(|| "-".to_string(), |d| d.to_string()),
)?;
}
writeln!(md)?;
}
}
{
let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
});
let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
});
write_cra_compliance_diff(&mut md, &old_cra, &new_cra)?;
}
writeln!(md, "---\n")?;
writeln!(md, "*Generated by sbom-tools*")?;
Ok(md)
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let mut md = String::new();
let title = config
.title
.clone()
.unwrap_or_else(|| "SBOM Report".to_string());
writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
writeln!(md, "**Format:** {}", sbom.document.format)?;
writeln!(md, "**Version:** {}", sbom.document.format_version)?;
if let Some(name) = &sbom.document.name {
writeln!(md, "**Name:** {}", escape_markdown_inline(name))?;
}
writeln!(md)?;
writeln!(md, "## Summary\n")?;
writeln!(md, "| Metric | Value |")?;
writeln!(md, "|--------|-------|")?;
writeln!(md, "| Total Components | {} |", sbom.component_count())?;
writeln!(md, "| Total Dependencies | {} |", sbom.edges.len())?;
let vuln_counts = sbom.vulnerability_counts();
writeln!(md, "| Total Vulnerabilities | {} |", vuln_counts.total())?;
writeln!(md, "| Critical | {} |", vuln_counts.critical)?;
writeln!(md, "| High | {} |", vuln_counts.high)?;
writeln!(md, "| Medium | {} |", vuln_counts.medium)?;
writeln!(md, "| Low | {} |", vuln_counts.low)?;
writeln!(md)?;
writeln!(md, "## Components\n")?;
writeln!(
md,
"| Name | Version | Ecosystem | License | Vulnerabilities |"
)?;
writeln!(
md,
"|------|---------|-----------|---------|-----------------|"
)?;
for comp in sbom.components.values() {
let license = comp
.licenses
.declared
.first()
.map(|l| escape_markdown_table(&l.expression));
let license = license.as_deref().unwrap_or("-");
writeln!(
md,
"| {} | {} | {} | {} | {} |",
escape_markdown_table(&comp.name),
escape_md_opt(comp.version.as_deref()),
comp.ecosystem
.as_ref()
.map(|e| escape_markdown_table(&e.to_string()))
.as_deref()
.unwrap_or("-"),
license,
comp.vulnerabilities.len()
)?;
}
{
let crypto_comps: Vec<_> = sbom
.components
.values()
.filter(|c| c.component_type == crate::model::ComponentType::Cryptographic)
.collect();
if !crypto_comps.is_empty() {
writeln!(md, "\n## Cryptographic Inventory\n")?;
writeln!(
md,
"| Name | Asset Type | Family | Primitive | Security Level | Quantum Level |"
)?;
writeln!(
md,
"|------|-----------|--------|-----------|---------------|--------------|"
)?;
for comp in &crypto_comps {
if let Some(cp) = &comp.crypto_properties {
let family = cp
.algorithm_properties
.as_ref()
.and_then(|a| a.algorithm_family.as_deref())
.unwrap_or("-");
let primitive = cp
.algorithm_properties
.as_ref()
.map(|a| a.primitive.to_string())
.unwrap_or_else(|| "-".to_string());
let sec_level = cp
.algorithm_properties
.as_ref()
.and_then(|a| a.classical_security_level)
.map(|l| format!("{l}"))
.unwrap_or_else(|| "-".to_string());
let quantum = cp
.algorithm_properties
.as_ref()
.and_then(|a| a.nist_quantum_security_level)
.map(|l| format!("{l}"))
.unwrap_or_else(|| "-".to_string());
writeln!(
md,
"| {} | {} | {} | {} | {} | {} |",
escape_markdown_table(&comp.name),
cp.asset_type,
escape_markdown_table(family),
primitive,
sec_level,
quantum,
)?;
} else {
writeln!(
md,
"| {} | - | - | - | - | - |",
escape_markdown_table(&comp.name),
)?;
}
}
let metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
if metrics.algorithms_count > 0 {
writeln!(
md,
"\n**Quantum Readiness:** {:.0}% ({} safe / {} total algorithms)",
metrics.quantum_readiness_score(),
metrics.quantum_safe_count,
metrics.algorithms_count,
)?;
}
if !metrics.weak_algorithm_names.is_empty() {
writeln!(
md,
"\n**Weak Algorithms:** {}",
metrics.weak_algorithm_names.join(", ")
)?;
}
}
}
{
let cra = config
.view_cra_compliance
.clone()
.unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
write_cra_compliance_view(&mut md, &cra)?;
}
Ok(md)
}
fn format(&self) -> ReportFormat {
ReportFormat::Markdown
}
}
fn delta_indicator(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
if old_val == new_val {
""
} else if (new_val < old_val) == lower_is_better {
" (+)" } else {
" (!)" }
}
fn compliance_score(result: &ComplianceResult) -> u8 {
let total = result.violations.len() + 1; let issues = result.error_count + result.warning_count;
let score = if issues >= total {
0
} else {
((total - issues) * 100) / total
};
score.min(100) as u8
}
fn write_cra_compliance_diff(
md: &mut String,
old: &ComplianceResult,
new: &ComplianceResult,
) -> std::fmt::Result {
writeln!(md, "## CRA Compliance\n")?;
let old_status = if old.is_compliant {
"Compliant"
} else {
"Non-compliant"
};
let new_status = if new.is_compliant {
"Compliant"
} else {
"Non-compliant"
};
let old_score = compliance_score(old);
let new_score = compliance_score(new);
let err_delta = delta_indicator(old.error_count, new.error_count, true);
let warn_delta = delta_indicator(old.warning_count, new.warning_count, true);
let score_delta = delta_indicator(old_score.into(), new_score.into(), false);
writeln!(md, "| | Old SBOM | New SBOM | Trend |")?;
writeln!(md, "|--|----------|----------|-------|")?;
writeln!(md, "| **Status** | {old_status} | {new_status} | |")?;
writeln!(
md,
"| **Score** | {old_score}% | {new_score}% | {score_delta} |"
)?;
writeln!(
md,
"| **Level** | {} | {} | |",
old.level.name(),
new.level.name()
)?;
writeln!(
md,
"| **Errors** | {} | {} | {err_delta} |",
old.error_count, new.error_count
)?;
writeln!(
md,
"| **Warnings** | {} | {} | {warn_delta} |",
old.warning_count, new.warning_count
)?;
writeln!(md)?;
if !new.violations.is_empty() {
writeln!(md, "### Violations (New SBOM)\n")?;
write_violation_table(md, &new.violations)?;
}
Ok(())
}
fn write_cra_compliance_view(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
writeln!(md, "## CRA Compliance\n")?;
let status = if result.is_compliant {
"Compliant"
} else {
"Non-compliant"
};
let score = compliance_score(result);
writeln!(md, "**Status:** {status} ")?;
writeln!(md, "**Score:** {score}% ")?;
writeln!(md, "**Level:** {} ", result.level.name())?;
writeln!(
md,
"**Issues:** {} errors, {} warnings\n",
result.error_count, result.warning_count
)?;
if !result.violations.is_empty() {
write_violation_table(md, &result.violations)?;
}
Ok(())
}
fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
for v in violations {
let sev_ord = match v.severity {
ViolationSeverity::Error => 0,
ViolationSeverity::Warning => 1,
ViolationSeverity::Info => 2,
};
groups
.entry((sev_ord, v.category.name(), v.requirement.as_str()))
.or_default()
.push(v);
}
groups
.into_values()
.map(|group| {
if group.len() == 1 {
AggregatedViolation {
severity: group[0].severity,
category: group[0].category.name(),
requirement: &group[0].requirement,
message: group[0].message.clone(),
remediation: group[0].remediation_guidance(),
count: 1,
}
} else {
let elements: Vec<&str> =
group.iter().filter_map(|v| v.element.as_deref()).collect();
let message = if elements.is_empty() {
group[0].message.clone()
} else {
let preview: Vec<&str> = elements.iter().take(5).copied().collect();
let suffix = if elements.len() > 5 {
format!(", ... +{} more", elements.len() - 5)
} else {
String::new()
};
format!(
"{} components affected ({}{})",
elements.len(),
preview.join(", "),
suffix
)
};
AggregatedViolation {
severity: group[0].severity,
category: group[0].category.name(),
requirement: &group[0].requirement,
message,
remediation: group[0].remediation_guidance(),
count: group.len(),
}
}
})
.collect()
}
struct AggregatedViolation<'a> {
severity: ViolationSeverity,
category: &'a str,
requirement: &'a str,
message: String,
remediation: &'static str,
count: usize,
}
fn write_violation_table(
md: &mut String,
violations: &[crate::quality::Violation],
) -> std::fmt::Result {
let aggregated = aggregate_violations(violations);
writeln!(
md,
"| Severity | Category | Requirement | Message | Remediation |"
)?;
writeln!(
md,
"|----------|----------|-------------|---------|-------------|"
)?;
for v in &aggregated {
let severity = match v.severity {
ViolationSeverity::Error => "Error",
ViolationSeverity::Warning => "Warning",
ViolationSeverity::Info => "Info",
};
let count_suffix = if v.count > 1 {
format!(" (x{})", v.count)
} else {
String::new()
};
writeln!(
md,
"| {}{} | {} | {} | {} | {} |",
severity,
escape_markdown_table(&count_suffix),
escape_markdown_table(v.category),
escape_markdown_table(v.requirement),
escape_markdown_table(&v.message),
escape_markdown_table(v.remediation),
)?;
}
writeln!(md)?;
Ok(())
}
fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
match vuln.sla_status() {
SlaStatus::Overdue(days) => format!("{days}d late"),
SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
SlaStatus::NoDueDate => vuln
.days_since_published
.map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
}
}
fn format_vex_display(vex_state: Option<&crate::model::VexState>) -> &'static str {
match vex_state {
Some(crate::model::VexState::NotAffected) => "Not Affected",
Some(crate::model::VexState::Fixed) => "Fixed",
Some(crate::model::VexState::Affected) => "Affected",
Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
None => "-",
}
}