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::{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 !result.metadata_changes.is_empty() {
73 writeln!(md, "- [Metadata Changes](#metadata-changes)")?;
74 }
75 if config.includes(ReportType::Components) {
76 writeln!(md, "- [Component Changes](#component-changes)")?;
77 }
78 if config.includes(ReportType::Dependencies) {
79 writeln!(md, "- [Dependency Changes](#dependency-changes)")?;
80 }
81 if config.includes(ReportType::Licenses)
82 && (!result.licenses.new_licenses.is_empty()
83 || !result.licenses.removed_licenses.is_empty()
84 || !result.licenses.conflicts.is_empty())
85 {
86 writeln!(md, "- [License Changes](#license-changes)")?;
87 }
88 if config.includes(ReportType::Vulnerabilities)
89 && (!result.vulnerabilities.introduced.is_empty()
90 || !result.vulnerabilities.resolved.is_empty())
91 {
92 writeln!(md, "- [Vulnerability Changes](#vulnerability-changes)")?;
93 }
94 if result
95 .graph_summary
96 .as_ref()
97 .is_some_and(|s| s.total_changes > 0)
98 {
99 writeln!(md, "- [Graph Changes](#graph-changes)")?;
100 }
101 writeln!(md, "- [CRA Compliance](#cra-compliance)")?;
102 writeln!(md)?;
103 }
104
105 writeln!(md, "## Summary\n")?;
107 writeln!(md, "| Metric | Old SBOM | New SBOM |")?;
108 writeln!(md, "|--------|----------|----------|")?;
109 writeln!(
110 md,
111 "| **Format** | {} | {} |",
112 old_sbom.document.format, new_sbom.document.format
113 )?;
114 writeln!(
115 md,
116 "| **Components** | {} | {} |",
117 old_sbom.component_count(),
118 new_sbom.component_count()
119 )?;
120 writeln!(
121 md,
122 "| **Dependencies** | {} | {} |",
123 old_sbom.edges.len(),
124 new_sbom.edges.len()
125 )?;
126 writeln!(md)?;
127
128 writeln!(md, "### Change Summary\n")?;
129 writeln!(md, "| Category | Count |")?;
130 writeln!(md, "|----------|-------|")?;
131 writeln!(
132 md,
133 "| Components Added | {} |",
134 result.summary.components_added
135 )?;
136 writeln!(
137 md,
138 "| Components Removed | {} |",
139 result.summary.components_removed
140 )?;
141 writeln!(
142 md,
143 "| Components Modified | {} |",
144 result.summary.components_modified
145 )?;
146 writeln!(
147 md,
148 "| Vulnerabilities Introduced | {} |",
149 result.summary.vulnerabilities_introduced
150 )?;
151 writeln!(
152 md,
153 "| Vulnerabilities Resolved | {} |",
154 result.summary.vulnerabilities_resolved
155 )?;
156 writeln!(md, "| **Semantic Score** | {:.1} |", result.semantic_score)?;
157 writeln!(md)?;
158
159 if !result.metadata_changes.is_empty() {
161 writeln!(md, "## Metadata Changes\n")?;
162 writeln!(md, "| Field | Old | New |")?;
163 writeln!(md, "|-------|-----|-----|")?;
164 for change in &result.metadata_changes {
165 writeln!(
166 md,
167 "| {} | {} | {} |",
168 escape_markdown_table(&change.field),
169 escape_md_opt(change.old_value.as_deref()),
170 escape_md_opt(change.new_value.as_deref()),
171 )?;
172 }
173 writeln!(md)?;
174 }
175
176 if config.includes(ReportType::Components) {
178 writeln!(md, "## Component Changes\n")?;
179
180 if !result.components.added.is_empty() {
181 writeln!(md, "### Added Components\n")?;
182 writeln!(md, "| Name | Version | Ecosystem |")?;
183 writeln!(md, "|------|---------|-----------|")?;
184 for comp in &result.components.added {
185 writeln!(
186 md,
187 "| {} | {} | {} |",
188 escape_markdown_table(&comp.name),
189 escape_md_opt(comp.new_version.as_deref()),
190 escape_md_opt(comp.ecosystem.as_deref())
191 )?;
192 }
193 writeln!(md)?;
194 }
195
196 if !result.components.removed.is_empty() {
197 writeln!(md, "### Removed Components\n")?;
198 writeln!(md, "| Name | Version | Ecosystem |")?;
199 writeln!(md, "|------|---------|-----------|")?;
200 for comp in &result.components.removed {
201 writeln!(
202 md,
203 "| {} | {} | {} |",
204 escape_markdown_table(&comp.name),
205 escape_md_opt(comp.old_version.as_deref()),
206 escape_md_opt(comp.ecosystem.as_deref())
207 )?;
208 }
209 writeln!(md)?;
210 }
211
212 if !result.components.modified.is_empty() {
213 writeln!(md, "### Modified Components\n")?;
214 writeln!(md, "| Name | Old Version | New Version | Changes |")?;
215 writeln!(md, "|------|-------------|-------------|---------|")?;
216 for comp in &result.components.modified {
217 let changes: Vec<String> =
218 if comp.change_type == crate::diff::ChangeType::Unchanged {
219 vec!["_unchanged_".to_string()]
220 } else {
221 comp.field_changes
222 .iter()
223 .map(|c| escape_markdown_table(&c.field))
224 .collect()
225 };
226 writeln!(
227 md,
228 "| {} | {} | {} | {} |",
229 escape_markdown_table(&comp.name),
230 escape_md_opt(comp.old_version.as_deref()),
231 escape_md_opt(comp.new_version.as_deref()),
232 changes.join(", ")
233 )?;
234 }
235 writeln!(md)?;
236 }
237 }
238
239 if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
241 writeln!(md, "## Dependency Changes\n")?;
242
243 if !result.dependencies.added.is_empty() {
244 writeln!(md, "### Added Dependencies\n")?;
245 writeln!(md, "| From | To | Relationship |")?;
246 writeln!(md, "|------|----|--------------|")?;
247 for dep in &result.dependencies.added {
248 writeln!(
249 md,
250 "| {} | {} | {} |",
251 escape_markdown_table(&dep.from),
252 escape_markdown_table(&dep.to),
253 escape_markdown_table(&dep.relationship)
254 )?;
255 }
256 writeln!(md)?;
257 }
258
259 if !result.dependencies.removed.is_empty() {
260 writeln!(md, "### Removed Dependencies\n")?;
261 writeln!(md, "| From | To | Relationship |")?;
262 writeln!(md, "|------|----|--------------|")?;
263 for dep in &result.dependencies.removed {
264 writeln!(
265 md,
266 "| {} | {} | {} |",
267 escape_markdown_table(&dep.from),
268 escape_markdown_table(&dep.to),
269 escape_markdown_table(&dep.relationship)
270 )?;
271 }
272 writeln!(md)?;
273 }
274 }
275
276 if config.includes(ReportType::Licenses)
278 && (!result.licenses.new_licenses.is_empty()
279 || !result.licenses.removed_licenses.is_empty()
280 || !result.licenses.conflicts.is_empty())
281 {
282 writeln!(md, "## License Changes\n")?;
283
284 if !result.licenses.new_licenses.is_empty() {
285 writeln!(md, "### New Licenses\n")?;
286 for lic in &result.licenses.new_licenses {
287 let escaped_components: Vec<String> = lic
288 .components
289 .iter()
290 .map(|c| escape_markdown_list(c))
291 .collect();
292 writeln!(
293 md,
294 "- **{}**: {}",
295 escape_markdown_list(&lic.license),
296 escaped_components.join(", ")
297 )?;
298 }
299 writeln!(md)?;
300 }
301
302 if !result.licenses.removed_licenses.is_empty() {
303 writeln!(md, "### Removed Licenses\n")?;
304 for lic in &result.licenses.removed_licenses {
305 let escaped_components: Vec<String> = lic
306 .components
307 .iter()
308 .map(|c| escape_markdown_list(c))
309 .collect();
310 writeln!(
311 md,
312 "- **{}**: {}",
313 escape_markdown_list(&lic.license),
314 escaped_components.join(", ")
315 )?;
316 }
317 writeln!(md)?;
318 }
319
320 if !result.licenses.conflicts.is_empty() {
321 writeln!(md, "### License Conflicts\n")?;
322 writeln!(md, "| License A | License B | Component | Description |")?;
323 writeln!(md, "|-----------|-----------|-----------|-------------|")?;
324 for conflict in &result.licenses.conflicts {
325 writeln!(
326 md,
327 "| {} | {} | {} | {} |",
328 escape_markdown_table(&conflict.license_a),
329 escape_markdown_table(&conflict.license_b),
330 escape_markdown_table(&conflict.component),
331 escape_markdown_table(&conflict.description)
332 )?;
333 }
334 writeln!(md)?;
335 }
336 }
337
338 if config.includes(ReportType::Vulnerabilities)
340 && (!result.vulnerabilities.introduced.is_empty()
341 || !result.vulnerabilities.resolved.is_empty())
342 {
343 writeln!(md, "## Vulnerability Changes\n")?;
344
345 if !result.vulnerabilities.introduced.is_empty() {
346 writeln!(md, "### Introduced Vulnerabilities\n")?;
347 writeln!(
348 md,
349 "| ID | Severity | CVSS | KEV | EPSS | SLA | Type | Component | Version | VEX |"
350 )?;
351 writeln!(
352 md,
353 "|----|----------|------|-----|------|-----|------|-----------|---------|-----|"
354 )?;
355 for vuln in &result.vulnerabilities.introduced {
356 let depth_label = match vuln.component_depth {
357 Some(1) => "Direct",
358 Some(_) => "Transitive",
359 None => "-",
360 };
361 let sla_display = format_sla_display(vuln);
362 let vex_display = format_vex_display(vuln.vex_state.as_ref());
363 let kev_display = if vuln.is_kev { "⚠ KEV" } else { "-" };
364 let epss_display = format_epss_display(vuln.epss_score);
365 writeln!(
366 md,
367 "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
368 escape_markdown_table(&vuln.id),
369 escape_markdown_table(&vuln.severity),
370 vuln.cvss_score
371 .map(|s| format!("{s:.1}"))
372 .as_deref()
373 .unwrap_or("-"),
374 kev_display,
375 epss_display,
376 escape_markdown_table(&sla_display),
377 depth_label,
378 escape_markdown_table(&vuln.component_name),
379 escape_md_opt(vuln.version.as_deref()),
380 vex_display,
381 )?;
382 }
383 writeln!(md)?;
384 }
385
386 if !result.vulnerabilities.resolved.is_empty() {
387 writeln!(md, "### Resolved Vulnerabilities\n")?;
388 writeln!(md, "| ID | Severity | SLA | Type | Component | VEX |")?;
389 writeln!(md, "|----|----------|-----|------|-----------|-----|")?;
390 for vuln in &result.vulnerabilities.resolved {
391 let depth_label = match vuln.component_depth {
392 Some(1) => "Direct",
393 Some(_) => "Transitive",
394 None => "-",
395 };
396 let sla_display = format_sla_display(vuln);
397 let vex_display = format_vex_display(vuln.vex_state.as_ref());
398 writeln!(
399 md,
400 "| {} | {} | {} | {} | {} | {} |",
401 escape_markdown_table(&vuln.id),
402 escape_markdown_table(&vuln.severity),
403 escape_markdown_table(&sla_display),
404 depth_label,
405 escape_markdown_table(&vuln.component_name),
406 vex_display,
407 )?;
408 }
409 writeln!(md)?;
410 }
411 }
412
413 {
415 let vex_summary = result.vulnerabilities.vex_summary();
416 if vex_summary.total_vulns > 0 {
417 writeln!(md, "### VEX Coverage\n")?;
418 writeln!(md, "| Metric | Value |")?;
419 writeln!(md, "|--------|-------|")?;
420 writeln!(
421 md,
422 "| Coverage | {:.1}% ({}/{}) |",
423 vex_summary.coverage_pct, vex_summary.with_vex, vex_summary.total_vulns
424 )?;
425 writeln!(md, "| Actionable | {} |", vex_summary.actionable)?;
426 for (state, count) in &vex_summary.by_state {
427 writeln!(md, "| {state} | {count} |")?;
428 }
429 writeln!(md)?;
430 }
431 }
432
433 if let Some(ref summary) = result.graph_summary
435 && summary.total_changes > 0
436 {
437 writeln!(md, "## Graph Changes\n")?;
438 writeln!(md, "| Type | Count |")?;
439 writeln!(md, "|------|-------|")?;
440 writeln!(
441 md,
442 "| Dependencies Added | {} |",
443 summary.dependencies_added
444 )?;
445 writeln!(
446 md,
447 "| Dependencies Removed | {} |",
448 summary.dependencies_removed
449 )?;
450 writeln!(
451 md,
452 "| Relationship Changed | {} |",
453 summary.relationship_changed
454 )?;
455 writeln!(md, "| Reparented | {} |", summary.reparented)?;
456 writeln!(md, "| Depth Changed | {} |", summary.depth_changed)?;
457 writeln!(md, "| **Total** | **{}** |", summary.total_changes)?;
458 writeln!(md)?;
459
460 if !result.graph_changes.is_empty() {
462 writeln!(md, "### Graph Change Details\n")?;
463 writeln!(md, "| Impact | Type | Component | Details |")?;
464 writeln!(md, "|--------|------|-----------|---------|")?;
465 for change in &result.graph_changes {
466 let impact = change.impact.as_str().to_uppercase();
467 let (change_type, details) = match &change.change {
468 crate::diff::DependencyChangeType::DependencyAdded {
469 dependency_name,
470 ..
471 } => ("Added", format!("+ {dependency_name}")),
472 crate::diff::DependencyChangeType::DependencyRemoved {
473 dependency_name,
474 ..
475 } => ("Removed", format!("- {dependency_name}")),
476 crate::diff::DependencyChangeType::RelationshipChanged {
477 dependency_name,
478 old_relationship,
479 new_relationship,
480 ..
481 } => (
482 "Rel Changed",
483 format!("{dependency_name}: {old_relationship} → {new_relationship}"),
484 ),
485 crate::diff::DependencyChangeType::Reparented {
486 old_parent_name,
487 new_parent_name,
488 ..
489 } => (
490 "Reparented",
491 format!("{old_parent_name} → {new_parent_name}"),
492 ),
493 crate::diff::DependencyChangeType::DepthChanged {
494 old_depth,
495 new_depth,
496 } => {
497 let od = if *old_depth == u32::MAX {
498 "unreachable".to_string()
499 } else {
500 old_depth.to_string()
501 };
502 let nd = if *new_depth == u32::MAX {
503 "unreachable".to_string()
504 } else {
505 new_depth.to_string()
506 };
507 ("Depth", format!("{od} → {nd}"))
508 }
509 };
510 writeln!(
511 md,
512 "| {} | {} | {} | {} |",
513 escape_markdown_table(&impact),
514 change_type,
515 escape_markdown_table(&change.component_name),
516 escape_markdown_table(&details),
517 )?;
518 }
519 writeln!(md)?;
520 }
521
522 if summary.by_impact.critical > 0 || summary.by_impact.high > 0 {
523 writeln!(md, "### Impact Summary\n")?;
524 writeln!(md, "| Impact | Count |")?;
525 writeln!(md, "|--------|-------|")?;
526 if summary.by_impact.critical > 0 {
527 writeln!(md, "| Critical | {} |", summary.by_impact.critical)?;
528 }
529 if summary.by_impact.high > 0 {
530 writeln!(md, "| High | {} |", summary.by_impact.high)?;
531 }
532 if summary.by_impact.medium > 0 {
533 writeln!(md, "| Medium | {} |", summary.by_impact.medium)?;
534 }
535 if summary.by_impact.low > 0 {
536 writeln!(md, "| Low | {} |", summary.by_impact.low)?;
537 }
538 writeln!(md)?;
539 }
540 }
541
542 {
544 let eol_components: Vec<_> = new_sbom
545 .components
546 .values()
547 .filter(|c| {
548 c.eol.as_ref().is_some_and(|e| {
549 matches!(
550 e.status,
551 crate::model::EolStatus::EndOfLife
552 | crate::model::EolStatus::ApproachingEol
553 )
554 })
555 })
556 .collect();
557
558 if !eol_components.is_empty() {
559 writeln!(md, "## End-of-Life Components\n")?;
560 writeln!(md, "| Component | Version | Status | Product | EOL Date |")?;
561 writeln!(md, "|-----------|---------|--------|---------|----------|")?;
562 for comp in &eol_components {
563 let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
564 writeln!(
565 md,
566 "| {} | {} | {} | {} | {} |",
567 escape_markdown_table(&comp.name),
568 escape_md_opt(comp.version.as_deref()),
569 escape_markdown_table(eol.status.label()),
570 escape_markdown_table(&eol.product),
571 eol.eol_date
572 .map_or_else(|| "-".to_string(), |d| d.to_string()),
573 )?;
574 }
575 writeln!(md)?;
576 }
577 }
578
579 {
581 let old_cra = config.old_cra_compliance_or_bare(old_sbom);
582 let new_cra = config.new_cra_compliance_or_bare(new_sbom);
583 write_cra_compliance_diff(&mut md, &old_cra, &new_cra)?;
584 }
585
586 writeln!(md, "---\n")?;
588 writeln!(md, "*Generated by sbom-tools*")?;
589
590 Ok(md)
591 }
592
593 fn generate_view_report(
594 &self,
595 sbom: &NormalizedSbom,
596 config: &ReportConfig,
597 ) -> Result<String, ReportError> {
598 let mut md = String::new();
599
600 let title = config
602 .title
603 .clone()
604 .unwrap_or_else(|| "SBOM Report".to_string());
605 writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
606
607 writeln!(md, "**Format:** {}", sbom.document.format)?;
609 writeln!(md, "**Version:** {}", sbom.document.format_version)?;
610 if let Some(name) = &sbom.document.name {
611 writeln!(md, "**Name:** {}", escape_markdown_inline(name))?;
612 }
613 writeln!(md)?;
614
615 writeln!(md, "## Summary\n")?;
617 writeln!(md, "| Metric | Value |")?;
618 writeln!(md, "|--------|-------|")?;
619 writeln!(md, "| Total Components | {} |", sbom.component_count())?;
620 writeln!(md, "| Total Dependencies | {} |", sbom.edges.len())?;
621
622 let vuln_counts = sbom.vulnerability_counts();
623 writeln!(md, "| Total Vulnerabilities | {} |", vuln_counts.total())?;
624 writeln!(md, "| Critical | {} |", vuln_counts.critical)?;
625 writeln!(md, "| High | {} |", vuln_counts.high)?;
626 writeln!(md, "| Medium | {} |", vuln_counts.medium)?;
627 writeln!(md, "| Low | {} |", vuln_counts.low)?;
628 writeln!(md)?;
629
630 writeln!(md, "## Components\n")?;
632 writeln!(
633 md,
634 "| Name | Version | Ecosystem | License | Vulnerabilities |"
635 )?;
636 writeln!(
637 md,
638 "|------|---------|-----------|---------|-----------------|"
639 )?;
640
641 for comp in sbom.components.values() {
642 let license = comp
643 .licenses
644 .declared
645 .first()
646 .map(|l| escape_markdown_table(l.display_name()));
647 let license = license.as_deref().unwrap_or("-");
648 writeln!(
649 md,
650 "| {} | {} | {} | {} | {} |",
651 escape_markdown_table(&comp.name),
652 escape_md_opt(comp.version.as_deref()),
653 comp.ecosystem
654 .as_ref()
655 .map(|e| escape_markdown_table(&e.to_string()))
656 .as_deref()
657 .unwrap_or("-"),
658 license,
659 comp.vulnerabilities.len()
660 )?;
661 }
662
663 {
665 let crypto_comps: Vec<_> = sbom
666 .components
667 .values()
668 .filter(|c| c.component_type == crate::model::ComponentType::Cryptographic)
669 .collect();
670 if !crypto_comps.is_empty() {
671 writeln!(md, "\n## Cryptographic Inventory\n")?;
672 writeln!(
673 md,
674 "| Name | Asset Type | Family | Primitive | Security Level | Quantum Level |"
675 )?;
676 writeln!(
677 md,
678 "|------|-----------|--------|-----------|---------------|--------------|"
679 )?;
680 for comp in &crypto_comps {
681 if let Some(cp) = &comp.crypto_properties {
682 let family = cp
683 .algorithm_properties
684 .as_ref()
685 .and_then(|a| a.algorithm_family.as_deref())
686 .unwrap_or("-");
687 let primitive = cp
688 .algorithm_properties
689 .as_ref()
690 .map(|a| a.primitive.to_string())
691 .unwrap_or_else(|| "-".to_string());
692 let sec_level = cp
693 .algorithm_properties
694 .as_ref()
695 .and_then(|a| a.classical_security_level)
696 .map(|l| format!("{l}"))
697 .unwrap_or_else(|| "-".to_string());
698 let quantum = cp
699 .algorithm_properties
700 .as_ref()
701 .and_then(|a| a.nist_quantum_security_level)
702 .map(|l| format!("{l}"))
703 .unwrap_or_else(|| "-".to_string());
704 writeln!(
705 md,
706 "| {} | {} | {} | {} | {} | {} |",
707 escape_markdown_table(&comp.name),
708 cp.asset_type,
709 escape_markdown_table(family),
710 primitive,
711 sec_level,
712 quantum,
713 )?;
714 } else {
715 writeln!(
716 md,
717 "| {} | - | - | - | - | - |",
718 escape_markdown_table(&comp.name),
719 )?;
720 }
721 }
722
723 let metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
725 if let Some(readiness) = metrics.quantum_readiness_score() {
726 writeln!(
727 md,
728 "\n**Quantum Readiness:** {:.0}% ({} safe / {} total algorithms)",
729 readiness, metrics.quantum_safe_count, metrics.algorithms_count,
730 )?;
731 }
732 if !metrics.weak_algorithm_names.is_empty() {
733 writeln!(
734 md,
735 "\n**Weak Algorithms:** {}",
736 metrics.weak_algorithm_names.join(", ")
737 )?;
738 }
739 }
740 }
741
742 {
744 let cra = config.view_cra_compliance_or_bare(sbom);
745 write_cra_compliance_view(&mut md, &cra)?;
746 }
747
748 Ok(md)
749 }
750
751 fn format(&self) -> ReportFormat {
752 ReportFormat::Markdown
753 }
754}
755
756fn delta_indicator(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
758 if old_val == new_val {
759 ""
760 } else if (new_val < old_val) == lower_is_better {
761 " (+)" } else {
763 " (!)" }
765}
766
767fn compliance_score(result: &ComplianceResult) -> u8 {
769 result.score().unwrap_or(0)
772}
773
774fn write_cra_compliance_diff(
776 md: &mut String,
777 old: &ComplianceResult,
778 new: &ComplianceResult,
779) -> std::fmt::Result {
780 writeln!(md, "## CRA Compliance\n")?;
781
782 let old_status = if old.is_compliant {
784 "Compliant"
785 } else {
786 "Non-compliant"
787 };
788 let new_status = if new.is_compliant {
789 "Compliant"
790 } else {
791 "Non-compliant"
792 };
793 let old_score = compliance_score(old);
794 let new_score = compliance_score(new);
795 let err_delta = delta_indicator(old.error_count, new.error_count, true);
796 let warn_delta = delta_indicator(old.warning_count, new.warning_count, true);
797 let score_delta = delta_indicator(old_score.into(), new_score.into(), false);
798
799 writeln!(md, "| | Old SBOM | New SBOM | Trend |")?;
800 writeln!(md, "|--|----------|----------|-------|")?;
801 writeln!(md, "| **Status** | {old_status} | {new_status} | |")?;
802 writeln!(
803 md,
804 "| **Score** | {old_score}% | {new_score}% | {score_delta} |"
805 )?;
806 writeln!(
807 md,
808 "| **Level** | {} | {} | |",
809 old.level.name(),
810 new.level.name()
811 )?;
812 writeln!(
813 md,
814 "| **Errors** | {} | {} | {err_delta} |",
815 old.error_count, new.error_count
816 )?;
817 writeln!(
818 md,
819 "| **Warnings** | {} | {} | {warn_delta} |",
820 old.warning_count, new.warning_count
821 )?;
822 writeln!(md)?;
823
824 write_conformity_assessment_md(md, new)?;
826 write_reporting_channels_md(md, new)?;
828
829 write_compact_diff_violation_summary(md, new)?;
830
831 Ok(())
832}
833
834fn write_compact_diff_violation_summary(
835 md: &mut String,
836 result: &ComplianceResult,
837) -> std::fmt::Result {
838 if result.violations.is_empty() {
839 return Ok(());
840 }
841
842 let group_count = count_violation_groups(&result.violations);
843 writeln!(md, "### Violation Summary (New SBOM)\n")?;
844 writeln!(
845 md,
846 "- {} total findings across {group_count} distinct requirement groups.",
847 result.violations.len(),
848 )?;
849 writeln!(
850 md,
851 "- Re-run with `sbom-tools diff ... -o json` or `-o sarif` for the full CRA violation detail.\n"
852 )?;
853
854 Ok(())
855}
856
857fn write_cra_compliance_view(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
859 writeln!(md, "## CRA Compliance\n")?;
860
861 let status = if result.is_compliant {
862 "Compliant"
863 } else {
864 "Non-compliant"
865 };
866 let score = compliance_score(result);
867 writeln!(md, "**Status:** {status} ")?;
868 writeln!(md, "**Score:** {score}% ")?;
869 writeln!(md, "**Level:** {} ", result.level.name())?;
870 writeln!(
871 md,
872 "**Issues:** {} errors, {} warnings\n",
873 result.error_count, result.warning_count
874 )?;
875
876 write_conformity_assessment_md(md, result)?;
877 write_reporting_channels_md(md, result)?;
878
879 if !result.violations.is_empty() {
880 write_violation_table(md, &result.violations)?;
881 }
882
883 Ok(())
884}
885
886fn write_conformity_assessment_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
890 let Some(summary) = result.conformity_summary.as_ref() else {
891 return Ok(());
892 };
893 writeln!(md, "### Conformity Assessment (CRA Annex VIII)\n")?;
894 writeln!(md, "- **Product class:** {}", summary.product_class.name())?;
895 writeln!(md, "- **Conformity route:** {}\n", summary.route.name())?;
896 writeln!(md, "| Evidence | Status | Detail |")?;
897 writeln!(md, "|----------|--------|--------|")?;
898 for ev in &summary.evidence {
899 let status = if ev.satisfied {
900 "✅ Present"
901 } else {
902 "❌ Missing"
903 };
904 writeln!(md, "| {} | {} | {} |", ev.label, status, ev.detail)?;
905 }
906 writeln!(md)?;
907 Ok(())
908}
909
910fn write_reporting_channels_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
920 if !result.level.is_cra() {
921 return Ok(());
922 }
923
924 let psirt = channel_status(result, "Art. 14: PSIRT");
925 let early = channel_status(result, "Art. 14(2)(a)");
926 let incident = channel_status(result, "Art. 14(2)(b)");
927 let enisa = channel_status(result, "Art. 14(7)");
928
929 writeln!(md, "### Reporting Channels (CRA Art. 14)\n")?;
930 writeln!(md, "| Channel | Status |")?;
931 writeln!(md, "|---------|--------|")?;
932 writeln!(md, "| PSIRT contact | {} |", psirt.label())?;
933 writeln!(
934 md,
935 "| 24-hour early warning (Art. 14(2)(a)) | {} |",
936 early.label()
937 )?;
938 writeln!(
939 md,
940 "| 72-hour notification (Art. 14(2)(b)) | {} |",
941 incident.label()
942 )?;
943 writeln!(
944 md,
945 "| ENISA single reporting platform (Art. 14(7)) | {} |",
946 enisa.label()
947 )?;
948 writeln!(md)?;
949 writeln!(
950 md,
951 "_Article 14 reporting obligations apply from 11 September 2026. \
952 Channels marked 'Missing (pre-deadline)' surface as Info; \
953 after the deadline they become Warnings._\n"
954 )?;
955 Ok(())
956}
957
958fn channel_status(result: &ComplianceResult, needle: &str) -> ChannelStatus {
959 match result
960 .violations
961 .iter()
962 .find(|v| v.requirement.contains(needle))
963 {
964 None => ChannelStatus::Documented,
965 Some(v) => match v.severity {
966 ViolationSeverity::Warning | ViolationSeverity::Error => {
967 ChannelStatus::MissingPostDeadline
968 }
969 ViolationSeverity::Info => ChannelStatus::MissingPreDeadline,
970 },
971 }
972}
973
974#[derive(Debug, Clone, Copy, PartialEq, Eq)]
975enum ChannelStatus {
976 Documented,
977 MissingPreDeadline,
978 MissingPostDeadline,
979}
980
981impl ChannelStatus {
982 fn label(self) -> &'static str {
983 match self {
984 Self::Documented => "Documented",
985 Self::MissingPreDeadline => "Missing (pre-deadline 2026-09-11)",
986 Self::MissingPostDeadline => "Missing",
987 }
988 }
989}
990
991fn count_violation_groups(violations: &[crate::quality::Violation]) -> usize {
994 use std::collections::HashSet;
995 let mut groups: HashSet<(u8, &str, &str)> = HashSet::new();
996 for v in violations {
997 let sev_ord = match v.severity {
998 ViolationSeverity::Error => 0,
999 ViolationSeverity::Warning => 1,
1000 ViolationSeverity::Info => 2,
1001 };
1002 groups.insert((sev_ord, v.category.name(), v.requirement.as_str()));
1003 }
1004 groups.len()
1005}
1006
1007fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
1011 use std::collections::BTreeMap;
1012
1013 let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
1015 for v in violations {
1016 let sev_ord = match v.severity {
1017 ViolationSeverity::Error => 0,
1018 ViolationSeverity::Warning => 1,
1019 ViolationSeverity::Info => 2,
1020 };
1021 groups
1022 .entry((sev_ord, v.category.name(), v.requirement.as_str()))
1023 .or_default()
1024 .push(v);
1025 }
1026
1027 groups
1028 .into_values()
1029 .map(|group| {
1030 let standard_refs = format_standard_refs(&group[0].standard_refs);
1031 if group.len() == 1 {
1032 AggregatedViolation {
1033 severity: group[0].severity,
1034 category: group[0].category.name(),
1035 requirement: &group[0].requirement,
1036 message: group[0].message.clone(),
1037 remediation: group[0].remediation_guidance(),
1038 count: 1,
1039 standard_refs,
1040 }
1041 } else {
1042 let elements: Vec<&str> =
1043 group.iter().filter_map(|v| v.element.as_deref()).collect();
1044 let message = if elements.is_empty() {
1045 group[0].message.clone()
1046 } else {
1047 let preview: Vec<&str> = elements.iter().take(5).copied().collect();
1048 let suffix = if elements.len() > 5 {
1049 format!(", ... +{} more", elements.len() - 5)
1050 } else {
1051 String::new()
1052 };
1053 format!(
1054 "{} components affected ({}{})",
1055 elements.len(),
1056 preview.join(", "),
1057 suffix
1058 )
1059 };
1060 AggregatedViolation {
1061 severity: group[0].severity,
1062 category: group[0].category.name(),
1063 requirement: &group[0].requirement,
1064 message,
1065 remediation: group[0].remediation_guidance(),
1066 count: group.len(),
1067 standard_refs,
1068 }
1069 }
1070 })
1071 .collect()
1072}
1073
1074struct AggregatedViolation<'a> {
1075 severity: ViolationSeverity,
1076 category: &'a str,
1077 requirement: &'a str,
1078 message: String,
1079 remediation: &'static str,
1080 count: usize,
1081 standard_refs: String,
1084}
1085
1086fn format_standard_refs(refs: &[crate::quality::StandardRef]) -> String {
1088 use std::fmt::Write;
1089 let mut out = String::new();
1090 for (i, r) in refs.iter().enumerate() {
1091 if i > 0 {
1092 out.push_str(", ");
1093 }
1094 let _ = write!(out, "{}: {}", r.standard.label(), r.id);
1095 }
1096 out
1097}
1098
1099fn write_violation_table(
1101 md: &mut String,
1102 violations: &[crate::quality::Violation],
1103) -> std::fmt::Result {
1104 let aggregated = aggregate_violations(violations);
1105 writeln!(
1106 md,
1107 "| Severity | Category | Standard refs | Requirement | Message | Remediation |"
1108 )?;
1109 writeln!(
1110 md,
1111 "|----------|----------|---------------|-------------|---------|-------------|"
1112 )?;
1113 for v in &aggregated {
1114 let severity = match v.severity {
1115 ViolationSeverity::Error => "Error",
1116 ViolationSeverity::Warning => "Warning",
1117 ViolationSeverity::Info => "Info",
1118 };
1119 let count_suffix = if v.count > 1 {
1120 format!(" (x{})", v.count)
1121 } else {
1122 String::new()
1123 };
1124 writeln!(
1125 md,
1126 "| {}{} | {} | {} | {} | {} | {} |",
1127 severity,
1128 escape_markdown_table(&count_suffix),
1129 escape_markdown_table(v.category),
1130 escape_markdown_table(&v.standard_refs),
1131 escape_markdown_table(v.requirement),
1132 escape_markdown_table(&v.message),
1133 escape_markdown_table(v.remediation),
1134 )?;
1135 }
1136 writeln!(md)?;
1137 Ok(())
1138}
1139
1140fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
1142 match vuln.sla_status() {
1143 SlaStatus::Overdue(days) => format!("{days}d late"),
1144 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
1145 SlaStatus::NoDueDate => vuln
1146 .days_since_published
1147 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
1148 }
1149}
1150
1151fn format_vex_display(vex_state: Option<&crate::model::VexState>) -> &'static str {
1152 match vex_state {
1153 Some(crate::model::VexState::NotAffected) => "Not Affected",
1154 Some(crate::model::VexState::Fixed) => "Fixed",
1155 Some(crate::model::VexState::Affected) => "Affected",
1156 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
1157 None => "-",
1158 }
1159}
1160
1161fn format_epss_display(epss_score: Option<f64>) -> String {
1163 epss_score.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0))
1164}