1use super::escape::{
4 escape_markdown_inline, escape_markdown_list, escape_markdown_table, escape_md_opt,
5};
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
10use std::fmt::Write;
11
12pub struct MarkdownReporter {
14 include_toc: bool,
16}
17
18impl MarkdownReporter {
19 #[must_use]
21 pub const fn new() -> Self {
22 Self { include_toc: true }
23 }
24
25 #[must_use]
27 pub const fn include_toc(mut self, include: bool) -> Self {
28 self.include_toc = include;
29 self
30 }
31}
32
33impl Default for MarkdownReporter {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ReportGenerator for MarkdownReporter {
40 fn generate_diff_report(
41 &self,
42 result: &DiffResult,
43 old_sbom: &NormalizedSbom,
44 new_sbom: &NormalizedSbom,
45 config: &ReportConfig,
46 ) -> Result<String, ReportError> {
47 let mut md = String::new();
48
49 let title = config
51 .title
52 .clone()
53 .unwrap_or_else(|| "SBOM Diff Report".to_string());
54 writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
55
56 writeln!(
58 md,
59 "**Generated by:** sbom-tools v{}",
60 env!("CARGO_PKG_VERSION")
61 )?;
62 writeln!(
63 md,
64 "**Date:** {}\n",
65 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
66 )?;
67
68 if self.include_toc {
70 writeln!(md, "## Table of Contents\n")?;
71 writeln!(md, "- [Summary](#summary)")?;
72 if config.includes(ReportType::Components) {
73 writeln!(md, "- [Component Changes](#component-changes)")?;
74 }
75 if config.includes(ReportType::Dependencies) {
76 writeln!(md, "- [Dependency Changes](#dependency-changes)")?;
77 }
78 if config.includes(ReportType::Licenses)
79 && (!result.licenses.new_licenses.is_empty()
80 || !result.licenses.removed_licenses.is_empty()
81 || !result.licenses.conflicts.is_empty())
82 {
83 writeln!(md, "- [License Changes](#license-changes)")?;
84 }
85 if config.includes(ReportType::Vulnerabilities)
86 && (!result.vulnerabilities.introduced.is_empty()
87 || !result.vulnerabilities.resolved.is_empty())
88 {
89 writeln!(md, "- [Vulnerability Changes](#vulnerability-changes)")?;
90 }
91 if result
92 .graph_summary
93 .as_ref()
94 .is_some_and(|s| s.total_changes > 0)
95 {
96 writeln!(md, "- [Graph Changes](#graph-changes)")?;
97 }
98 writeln!(md, "- [CRA Compliance](#cra-compliance)")?;
99 writeln!(md)?;
100 }
101
102 writeln!(md, "## Summary\n")?;
104 writeln!(md, "| Metric | Old SBOM | New SBOM |")?;
105 writeln!(md, "|--------|----------|----------|")?;
106 writeln!(
107 md,
108 "| **Format** | {} | {} |",
109 old_sbom.document.format, new_sbom.document.format
110 )?;
111 writeln!(
112 md,
113 "| **Components** | {} | {} |",
114 old_sbom.component_count(),
115 new_sbom.component_count()
116 )?;
117 writeln!(
118 md,
119 "| **Dependencies** | {} | {} |",
120 old_sbom.edges.len(),
121 new_sbom.edges.len()
122 )?;
123 writeln!(md)?;
124
125 writeln!(md, "### Change Summary\n")?;
126 writeln!(md, "| Category | Count |")?;
127 writeln!(md, "|----------|-------|")?;
128 writeln!(
129 md,
130 "| Components Added | {} |",
131 result.summary.components_added
132 )?;
133 writeln!(
134 md,
135 "| Components Removed | {} |",
136 result.summary.components_removed
137 )?;
138 writeln!(
139 md,
140 "| Components Modified | {} |",
141 result.summary.components_modified
142 )?;
143 writeln!(
144 md,
145 "| Vulnerabilities Introduced | {} |",
146 result.summary.vulnerabilities_introduced
147 )?;
148 writeln!(
149 md,
150 "| Vulnerabilities Resolved | {} |",
151 result.summary.vulnerabilities_resolved
152 )?;
153 writeln!(md, "| **Semantic Score** | {:.1} |", result.semantic_score)?;
154 writeln!(md)?;
155
156 if config.includes(ReportType::Components) {
158 writeln!(md, "## Component Changes\n")?;
159
160 if !result.components.added.is_empty() {
161 writeln!(md, "### Added Components\n")?;
162 writeln!(md, "| Name | Version | Ecosystem |")?;
163 writeln!(md, "|------|---------|-----------|")?;
164 for comp in &result.components.added {
165 writeln!(
166 md,
167 "| {} | {} | {} |",
168 escape_markdown_table(&comp.name),
169 escape_md_opt(comp.new_version.as_deref()),
170 escape_md_opt(comp.ecosystem.as_deref())
171 )?;
172 }
173 writeln!(md)?;
174 }
175
176 if !result.components.removed.is_empty() {
177 writeln!(md, "### Removed Components\n")?;
178 writeln!(md, "| Name | Version | Ecosystem |")?;
179 writeln!(md, "|------|---------|-----------|")?;
180 for comp in &result.components.removed {
181 writeln!(
182 md,
183 "| {} | {} | {} |",
184 escape_markdown_table(&comp.name),
185 escape_md_opt(comp.old_version.as_deref()),
186 escape_md_opt(comp.ecosystem.as_deref())
187 )?;
188 }
189 writeln!(md)?;
190 }
191
192 if !result.components.modified.is_empty() {
193 writeln!(md, "### Modified Components\n")?;
194 writeln!(md, "| Name | Old Version | New Version | Changes |")?;
195 writeln!(md, "|------|-------------|-------------|---------|")?;
196 for comp in &result.components.modified {
197 let changes: Vec<String> = comp
198 .field_changes
199 .iter()
200 .map(|c| escape_markdown_table(&c.field))
201 .collect();
202 writeln!(
203 md,
204 "| {} | {} | {} | {} |",
205 escape_markdown_table(&comp.name),
206 escape_md_opt(comp.old_version.as_deref()),
207 escape_md_opt(comp.new_version.as_deref()),
208 changes.join(", ")
209 )?;
210 }
211 writeln!(md)?;
212 }
213 }
214
215 if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
217 writeln!(md, "## Dependency Changes\n")?;
218
219 if !result.dependencies.added.is_empty() {
220 writeln!(md, "### Added Dependencies\n")?;
221 writeln!(md, "| From | To | Relationship |")?;
222 writeln!(md, "|------|----|--------------|")?;
223 for dep in &result.dependencies.added {
224 writeln!(
225 md,
226 "| {} | {} | {} |",
227 escape_markdown_table(&dep.from),
228 escape_markdown_table(&dep.to),
229 escape_markdown_table(&dep.relationship)
230 )?;
231 }
232 writeln!(md)?;
233 }
234
235 if !result.dependencies.removed.is_empty() {
236 writeln!(md, "### Removed Dependencies\n")?;
237 writeln!(md, "| From | To | Relationship |")?;
238 writeln!(md, "|------|----|--------------|")?;
239 for dep in &result.dependencies.removed {
240 writeln!(
241 md,
242 "| {} | {} | {} |",
243 escape_markdown_table(&dep.from),
244 escape_markdown_table(&dep.to),
245 escape_markdown_table(&dep.relationship)
246 )?;
247 }
248 writeln!(md)?;
249 }
250 }
251
252 if config.includes(ReportType::Licenses)
254 && (!result.licenses.new_licenses.is_empty()
255 || !result.licenses.removed_licenses.is_empty()
256 || !result.licenses.conflicts.is_empty())
257 {
258 writeln!(md, "## License Changes\n")?;
259
260 if !result.licenses.new_licenses.is_empty() {
261 writeln!(md, "### New Licenses\n")?;
262 for lic in &result.licenses.new_licenses {
263 let escaped_components: Vec<String> = lic
264 .components
265 .iter()
266 .map(|c| escape_markdown_list(c))
267 .collect();
268 writeln!(
269 md,
270 "- **{}**: {}",
271 escape_markdown_list(&lic.license),
272 escaped_components.join(", ")
273 )?;
274 }
275 writeln!(md)?;
276 }
277
278 if !result.licenses.removed_licenses.is_empty() {
279 writeln!(md, "### Removed Licenses\n")?;
280 for lic in &result.licenses.removed_licenses {
281 let escaped_components: Vec<String> = lic
282 .components
283 .iter()
284 .map(|c| escape_markdown_list(c))
285 .collect();
286 writeln!(
287 md,
288 "- **{}**: {}",
289 escape_markdown_list(&lic.license),
290 escaped_components.join(", ")
291 )?;
292 }
293 writeln!(md)?;
294 }
295
296 if !result.licenses.conflicts.is_empty() {
297 writeln!(md, "### License Conflicts\n")?;
298 writeln!(md, "| License A | License B | Component | Description |")?;
299 writeln!(md, "|-----------|-----------|-----------|-------------|")?;
300 for conflict in &result.licenses.conflicts {
301 writeln!(
302 md,
303 "| {} | {} | {} | {} |",
304 escape_markdown_table(&conflict.license_a),
305 escape_markdown_table(&conflict.license_b),
306 escape_markdown_table(&conflict.component),
307 escape_markdown_table(&conflict.description)
308 )?;
309 }
310 writeln!(md)?;
311 }
312 }
313
314 if config.includes(ReportType::Vulnerabilities)
316 && (!result.vulnerabilities.introduced.is_empty()
317 || !result.vulnerabilities.resolved.is_empty())
318 {
319 writeln!(md, "## Vulnerability Changes\n")?;
320
321 if !result.vulnerabilities.introduced.is_empty() {
322 writeln!(md, "### Introduced Vulnerabilities\n")?;
323 writeln!(
324 md,
325 "| ID | Severity | CVSS | SLA | Type | Component | Version | VEX |"
326 )?;
327 writeln!(
328 md,
329 "|----|----------|------|-----|------|-----------|---------|-----|"
330 )?;
331 for vuln in &result.vulnerabilities.introduced {
332 let depth_label = match vuln.component_depth {
333 Some(1) => "Direct",
334 Some(_) => "Transitive",
335 None => "-",
336 };
337 let sla_display = format_sla_display(vuln);
338 let vex_display = format_vex_display(vuln.vex_state.as_ref());
339 writeln!(
340 md,
341 "| {} | {} | {} | {} | {} | {} | {} | {} |",
342 escape_markdown_table(&vuln.id),
343 escape_markdown_table(&vuln.severity),
344 vuln.cvss_score
345 .map(|s| format!("{s:.1}"))
346 .as_deref()
347 .unwrap_or("-"),
348 escape_markdown_table(&sla_display),
349 depth_label,
350 escape_markdown_table(&vuln.component_name),
351 escape_md_opt(vuln.version.as_deref()),
352 vex_display,
353 )?;
354 }
355 writeln!(md)?;
356 }
357
358 if !result.vulnerabilities.resolved.is_empty() {
359 writeln!(md, "### Resolved Vulnerabilities\n")?;
360 writeln!(md, "| ID | Severity | SLA | Type | Component | VEX |")?;
361 writeln!(md, "|----|----------|-----|------|-----------|-----|")?;
362 for vuln in &result.vulnerabilities.resolved {
363 let depth_label = match vuln.component_depth {
364 Some(1) => "Direct",
365 Some(_) => "Transitive",
366 None => "-",
367 };
368 let sla_display = format_sla_display(vuln);
369 let vex_display = format_vex_display(vuln.vex_state.as_ref());
370 writeln!(
371 md,
372 "| {} | {} | {} | {} | {} | {} |",
373 escape_markdown_table(&vuln.id),
374 escape_markdown_table(&vuln.severity),
375 escape_markdown_table(&sla_display),
376 depth_label,
377 escape_markdown_table(&vuln.component_name),
378 vex_display,
379 )?;
380 }
381 writeln!(md)?;
382 }
383 }
384
385 if let Some(ref summary) = result.graph_summary
387 && summary.total_changes > 0
388 {
389 writeln!(md, "## Graph Changes\n")?;
390 writeln!(md, "| Type | Count |")?;
391 writeln!(md, "|------|-------|")?;
392 writeln!(
393 md,
394 "| Dependencies Added | {} |",
395 summary.dependencies_added
396 )?;
397 writeln!(
398 md,
399 "| Dependencies Removed | {} |",
400 summary.dependencies_removed
401 )?;
402 writeln!(
403 md,
404 "| Relationship Changed | {} |",
405 summary.relationship_changed
406 )?;
407 writeln!(md, "| Reparented | {} |", summary.reparented)?;
408 writeln!(md, "| Depth Changed | {} |", summary.depth_changed)?;
409 writeln!(md, "| **Total** | **{}** |", summary.total_changes)?;
410 writeln!(md)?;
411
412 if !result.graph_changes.is_empty() {
414 writeln!(md, "### Graph Change Details\n")?;
415 writeln!(md, "| Impact | Type | Component | Details |")?;
416 writeln!(md, "|--------|------|-----------|---------|")?;
417 for change in &result.graph_changes {
418 let impact = change.impact.as_str().to_uppercase();
419 let (change_type, details) = match &change.change {
420 crate::diff::DependencyChangeType::DependencyAdded {
421 dependency_name,
422 ..
423 } => ("Added", format!("+ {dependency_name}")),
424 crate::diff::DependencyChangeType::DependencyRemoved {
425 dependency_name,
426 ..
427 } => ("Removed", format!("- {dependency_name}")),
428 crate::diff::DependencyChangeType::RelationshipChanged {
429 dependency_name,
430 old_relationship,
431 new_relationship,
432 ..
433 } => (
434 "Rel Changed",
435 format!("{dependency_name}: {old_relationship} → {new_relationship}"),
436 ),
437 crate::diff::DependencyChangeType::Reparented {
438 old_parent_name,
439 new_parent_name,
440 ..
441 } => (
442 "Reparented",
443 format!("{old_parent_name} → {new_parent_name}"),
444 ),
445 crate::diff::DependencyChangeType::DepthChanged {
446 old_depth,
447 new_depth,
448 } => {
449 let od = if *old_depth == u32::MAX {
450 "unreachable".to_string()
451 } else {
452 old_depth.to_string()
453 };
454 let nd = if *new_depth == u32::MAX {
455 "unreachable".to_string()
456 } else {
457 new_depth.to_string()
458 };
459 ("Depth", format!("{od} → {nd}"))
460 }
461 };
462 writeln!(
463 md,
464 "| {} | {} | {} | {} |",
465 escape_markdown_table(&impact),
466 change_type,
467 escape_markdown_table(&change.component_name),
468 escape_markdown_table(&details),
469 )?;
470 }
471 writeln!(md)?;
472 }
473
474 if summary.by_impact.critical > 0 || summary.by_impact.high > 0 {
475 writeln!(md, "### Impact Summary\n")?;
476 writeln!(md, "| Impact | Count |")?;
477 writeln!(md, "|--------|-------|")?;
478 if summary.by_impact.critical > 0 {
479 writeln!(md, "| Critical | {} |", summary.by_impact.critical)?;
480 }
481 if summary.by_impact.high > 0 {
482 writeln!(md, "| High | {} |", summary.by_impact.high)?;
483 }
484 if summary.by_impact.medium > 0 {
485 writeln!(md, "| Medium | {} |", summary.by_impact.medium)?;
486 }
487 if summary.by_impact.low > 0 {
488 writeln!(md, "| Low | {} |", summary.by_impact.low)?;
489 }
490 writeln!(md)?;
491 }
492 }
493
494 {
496 let eol_components: Vec<_> = new_sbom
497 .components
498 .values()
499 .filter(|c| {
500 c.eol.as_ref().is_some_and(|e| {
501 matches!(
502 e.status,
503 crate::model::EolStatus::EndOfLife
504 | crate::model::EolStatus::ApproachingEol
505 )
506 })
507 })
508 .collect();
509
510 if !eol_components.is_empty() {
511 writeln!(md, "## End-of-Life Components\n")?;
512 writeln!(md, "| Component | Version | Status | Product | EOL Date |")?;
513 writeln!(md, "|-----------|---------|--------|---------|----------|")?;
514 for comp in &eol_components {
515 let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
516 writeln!(
517 md,
518 "| {} | {} | {} | {} | {} |",
519 escape_markdown_table(&comp.name),
520 escape_md_opt(comp.version.as_deref()),
521 escape_markdown_table(eol.status.label()),
522 escape_markdown_table(&eol.product),
523 eol.eol_date
524 .map_or_else(|| "-".to_string(), |d| d.to_string()),
525 )?;
526 }
527 writeln!(md)?;
528 }
529 }
530
531 {
533 let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
534 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
535 });
536 let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
537 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
538 });
539 write_cra_compliance_diff(&mut md, &old_cra, &new_cra)?;
540 }
541
542 writeln!(md, "---\n")?;
544 writeln!(md, "*Generated by sbom-tools*")?;
545
546 Ok(md)
547 }
548
549 fn generate_view_report(
550 &self,
551 sbom: &NormalizedSbom,
552 config: &ReportConfig,
553 ) -> Result<String, ReportError> {
554 let mut md = String::new();
555
556 let title = config
558 .title
559 .clone()
560 .unwrap_or_else(|| "SBOM Report".to_string());
561 writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
562
563 writeln!(md, "**Format:** {}", sbom.document.format)?;
565 writeln!(md, "**Version:** {}", sbom.document.format_version)?;
566 if let Some(name) = &sbom.document.name {
567 writeln!(md, "**Name:** {}", escape_markdown_inline(name))?;
568 }
569 writeln!(md)?;
570
571 writeln!(md, "## Summary\n")?;
573 writeln!(md, "| Metric | Value |")?;
574 writeln!(md, "|--------|-------|")?;
575 writeln!(md, "| Total Components | {} |", sbom.component_count())?;
576 writeln!(md, "| Total Dependencies | {} |", sbom.edges.len())?;
577
578 let vuln_counts = sbom.vulnerability_counts();
579 writeln!(md, "| Total Vulnerabilities | {} |", vuln_counts.total())?;
580 writeln!(md, "| Critical | {} |", vuln_counts.critical)?;
581 writeln!(md, "| High | {} |", vuln_counts.high)?;
582 writeln!(md, "| Medium | {} |", vuln_counts.medium)?;
583 writeln!(md, "| Low | {} |", vuln_counts.low)?;
584 writeln!(md)?;
585
586 writeln!(md, "## Components\n")?;
588 writeln!(
589 md,
590 "| Name | Version | Ecosystem | License | Vulnerabilities |"
591 )?;
592 writeln!(
593 md,
594 "|------|---------|-----------|---------|-----------------|"
595 )?;
596
597 for comp in sbom.components.values() {
598 let license = comp
599 .licenses
600 .declared
601 .first()
602 .map(|l| escape_markdown_table(&l.expression));
603 let license = license.as_deref().unwrap_or("-");
604 writeln!(
605 md,
606 "| {} | {} | {} | {} | {} |",
607 escape_markdown_table(&comp.name),
608 escape_md_opt(comp.version.as_deref()),
609 comp.ecosystem
610 .as_ref()
611 .map(|e| escape_markdown_table(&e.to_string()))
612 .as_deref()
613 .unwrap_or("-"),
614 license,
615 comp.vulnerabilities.len()
616 )?;
617 }
618
619 {
621 let cra = config
622 .view_cra_compliance
623 .clone()
624 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
625 write_cra_compliance_view(&mut md, &cra)?;
626 }
627
628 Ok(md)
629 }
630
631 fn format(&self) -> ReportFormat {
632 ReportFormat::Markdown
633 }
634}
635
636fn delta_indicator(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
638 if old_val == new_val {
639 ""
640 } else if (new_val < old_val) == lower_is_better {
641 " (+)" } else {
643 " (!)" }
645}
646
647fn compliance_score(result: &ComplianceResult) -> u8 {
649 let total = result.violations.len() + 1; let issues = result.error_count + result.warning_count;
651 let score = if issues >= total {
652 0
653 } else {
654 ((total - issues) * 100) / total
655 };
656 score.min(100) as u8
657}
658
659fn write_cra_compliance_diff(
661 md: &mut String,
662 old: &ComplianceResult,
663 new: &ComplianceResult,
664) -> std::fmt::Result {
665 writeln!(md, "## CRA Compliance\n")?;
666
667 let old_status = if old.is_compliant {
669 "Compliant"
670 } else {
671 "Non-compliant"
672 };
673 let new_status = if new.is_compliant {
674 "Compliant"
675 } else {
676 "Non-compliant"
677 };
678 let old_score = compliance_score(old);
679 let new_score = compliance_score(new);
680 let err_delta = delta_indicator(old.error_count, new.error_count, true);
681 let warn_delta = delta_indicator(old.warning_count, new.warning_count, true);
682 let score_delta = delta_indicator(old_score.into(), new_score.into(), false);
683
684 writeln!(md, "| | Old SBOM | New SBOM | Trend |")?;
685 writeln!(md, "|--|----------|----------|-------|")?;
686 writeln!(md, "| **Status** | {old_status} | {new_status} | |")?;
687 writeln!(
688 md,
689 "| **Score** | {old_score}% | {new_score}% | {score_delta} |"
690 )?;
691 writeln!(
692 md,
693 "| **Level** | {} | {} | |",
694 old.level.name(),
695 new.level.name()
696 )?;
697 writeln!(
698 md,
699 "| **Errors** | {} | {} | {err_delta} |",
700 old.error_count, new.error_count
701 )?;
702 writeln!(
703 md,
704 "| **Warnings** | {} | {} | {warn_delta} |",
705 old.warning_count, new.warning_count
706 )?;
707 writeln!(md)?;
708
709 if !new.violations.is_empty() {
711 writeln!(md, "### Violations (New SBOM)\n")?;
712 write_violation_table(md, &new.violations)?;
713 }
714
715 Ok(())
716}
717
718fn write_cra_compliance_view(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
720 writeln!(md, "## CRA Compliance\n")?;
721
722 let status = if result.is_compliant {
723 "Compliant"
724 } else {
725 "Non-compliant"
726 };
727 let score = compliance_score(result);
728 writeln!(md, "**Status:** {status} ")?;
729 writeln!(md, "**Score:** {score}% ")?;
730 writeln!(md, "**Level:** {} ", result.level.name())?;
731 writeln!(
732 md,
733 "**Issues:** {} errors, {} warnings\n",
734 result.error_count, result.warning_count
735 )?;
736
737 if !result.violations.is_empty() {
738 write_violation_table(md, &result.violations)?;
739 }
740
741 Ok(())
742}
743
744fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
748 use std::collections::BTreeMap;
749
750 let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
752 for v in violations {
753 let sev_ord = match v.severity {
754 ViolationSeverity::Error => 0,
755 ViolationSeverity::Warning => 1,
756 ViolationSeverity::Info => 2,
757 };
758 groups
759 .entry((sev_ord, v.category.name(), v.requirement.as_str()))
760 .or_default()
761 .push(v);
762 }
763
764 groups
765 .into_values()
766 .map(|group| {
767 if group.len() == 1 {
768 AggregatedViolation {
769 severity: group[0].severity,
770 category: group[0].category.name(),
771 requirement: &group[0].requirement,
772 message: group[0].message.clone(),
773 remediation: group[0].remediation_guidance(),
774 count: 1,
775 }
776 } else {
777 let elements: Vec<&str> =
778 group.iter().filter_map(|v| v.element.as_deref()).collect();
779 let message = if elements.is_empty() {
780 group[0].message.clone()
781 } else {
782 let preview: Vec<&str> = elements.iter().take(5).copied().collect();
783 let suffix = if elements.len() > 5 {
784 format!(", ... +{} more", elements.len() - 5)
785 } else {
786 String::new()
787 };
788 format!(
789 "{} components affected ({}{})",
790 elements.len(),
791 preview.join(", "),
792 suffix
793 )
794 };
795 AggregatedViolation {
796 severity: group[0].severity,
797 category: group[0].category.name(),
798 requirement: &group[0].requirement,
799 message,
800 remediation: group[0].remediation_guidance(),
801 count: group.len(),
802 }
803 }
804 })
805 .collect()
806}
807
808struct AggregatedViolation<'a> {
809 severity: ViolationSeverity,
810 category: &'a str,
811 requirement: &'a str,
812 message: String,
813 remediation: &'static str,
814 count: usize,
815}
816
817fn write_violation_table(
819 md: &mut String,
820 violations: &[crate::quality::Violation],
821) -> std::fmt::Result {
822 let aggregated = aggregate_violations(violations);
823 writeln!(
824 md,
825 "| Severity | Category | Requirement | Message | Remediation |"
826 )?;
827 writeln!(
828 md,
829 "|----------|----------|-------------|---------|-------------|"
830 )?;
831 for v in &aggregated {
832 let severity = match v.severity {
833 ViolationSeverity::Error => "Error",
834 ViolationSeverity::Warning => "Warning",
835 ViolationSeverity::Info => "Info",
836 };
837 let count_suffix = if v.count > 1 {
838 format!(" (x{})", v.count)
839 } else {
840 String::new()
841 };
842 writeln!(
843 md,
844 "| {}{} | {} | {} | {} | {} |",
845 severity,
846 escape_markdown_table(&count_suffix),
847 escape_markdown_table(v.category),
848 escape_markdown_table(v.requirement),
849 escape_markdown_table(&v.message),
850 escape_markdown_table(v.remediation),
851 )?;
852 }
853 writeln!(md)?;
854 Ok(())
855}
856
857fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
859 match vuln.sla_status() {
860 SlaStatus::Overdue(days) => format!("{days}d late"),
861 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
862 SlaStatus::NoDueDate => vuln
863 .days_since_published
864 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
865 }
866}
867
868fn format_vex_display(vex_state: Option<&crate::model::VexState>) -> &'static str {
869 match vex_state {
870 Some(crate::model::VexState::NotAffected) => "Not Affected",
871 Some(crate::model::VexState::Fixed) => "Fixed",
872 Some(crate::model::VexState::Affected) => "Affected",
873 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
874 None => "-",
875 }
876}