Skip to main content

sbom_tools/reports/
markdown.rs

1//! Markdown report generator.
2
3use 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
12/// Markdown report generator
13pub struct MarkdownReporter {
14    /// Include table of contents
15    include_toc: bool,
16}
17
18impl MarkdownReporter {
19    /// Create a new Markdown reporter
20    #[must_use]
21    pub const fn new() -> Self {
22        Self { include_toc: true }
23    }
24
25    /// Set whether to include table of contents
26    #[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        // Title
50        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        // Metadata
57        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        // Table of contents
69        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        // Summary section
106        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        // Document-metadata changes section
160        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        // Component changes section
177        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> = comp
218                        .field_changes
219                        .iter()
220                        .map(|c| escape_markdown_table(&c.field))
221                        .collect();
222                    writeln!(
223                        md,
224                        "| {} | {} | {} | {} |",
225                        escape_markdown_table(&comp.name),
226                        escape_md_opt(comp.old_version.as_deref()),
227                        escape_md_opt(comp.new_version.as_deref()),
228                        changes.join(", ")
229                    )?;
230                }
231                writeln!(md)?;
232            }
233        }
234
235        // Dependency changes section
236        if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
237            writeln!(md, "## Dependency Changes\n")?;
238
239            if !result.dependencies.added.is_empty() {
240                writeln!(md, "### Added Dependencies\n")?;
241                writeln!(md, "| From | To | Relationship |")?;
242                writeln!(md, "|------|----|--------------|")?;
243                for dep in &result.dependencies.added {
244                    writeln!(
245                        md,
246                        "| {} | {} | {} |",
247                        escape_markdown_table(&dep.from),
248                        escape_markdown_table(&dep.to),
249                        escape_markdown_table(&dep.relationship)
250                    )?;
251                }
252                writeln!(md)?;
253            }
254
255            if !result.dependencies.removed.is_empty() {
256                writeln!(md, "### Removed Dependencies\n")?;
257                writeln!(md, "| From | To | Relationship |")?;
258                writeln!(md, "|------|----|--------------|")?;
259                for dep in &result.dependencies.removed {
260                    writeln!(
261                        md,
262                        "| {} | {} | {} |",
263                        escape_markdown_table(&dep.from),
264                        escape_markdown_table(&dep.to),
265                        escape_markdown_table(&dep.relationship)
266                    )?;
267                }
268                writeln!(md)?;
269            }
270        }
271
272        // License changes section
273        if config.includes(ReportType::Licenses)
274            && (!result.licenses.new_licenses.is_empty()
275                || !result.licenses.removed_licenses.is_empty()
276                || !result.licenses.conflicts.is_empty())
277        {
278            writeln!(md, "## License Changes\n")?;
279
280            if !result.licenses.new_licenses.is_empty() {
281                writeln!(md, "### New Licenses\n")?;
282                for lic in &result.licenses.new_licenses {
283                    let escaped_components: Vec<String> = lic
284                        .components
285                        .iter()
286                        .map(|c| escape_markdown_list(c))
287                        .collect();
288                    writeln!(
289                        md,
290                        "- **{}**: {}",
291                        escape_markdown_list(&lic.license),
292                        escaped_components.join(", ")
293                    )?;
294                }
295                writeln!(md)?;
296            }
297
298            if !result.licenses.removed_licenses.is_empty() {
299                writeln!(md, "### Removed Licenses\n")?;
300                for lic in &result.licenses.removed_licenses {
301                    let escaped_components: Vec<String> = lic
302                        .components
303                        .iter()
304                        .map(|c| escape_markdown_list(c))
305                        .collect();
306                    writeln!(
307                        md,
308                        "- **{}**: {}",
309                        escape_markdown_list(&lic.license),
310                        escaped_components.join(", ")
311                    )?;
312                }
313                writeln!(md)?;
314            }
315
316            if !result.licenses.conflicts.is_empty() {
317                writeln!(md, "### License Conflicts\n")?;
318                writeln!(md, "| License A | License B | Component | Description |")?;
319                writeln!(md, "|-----------|-----------|-----------|-------------|")?;
320                for conflict in &result.licenses.conflicts {
321                    writeln!(
322                        md,
323                        "| {} | {} | {} | {} |",
324                        escape_markdown_table(&conflict.license_a),
325                        escape_markdown_table(&conflict.license_b),
326                        escape_markdown_table(&conflict.component),
327                        escape_markdown_table(&conflict.description)
328                    )?;
329                }
330                writeln!(md)?;
331            }
332        }
333
334        // Vulnerability changes section
335        if config.includes(ReportType::Vulnerabilities)
336            && (!result.vulnerabilities.introduced.is_empty()
337                || !result.vulnerabilities.resolved.is_empty())
338        {
339            writeln!(md, "## Vulnerability Changes\n")?;
340
341            if !result.vulnerabilities.introduced.is_empty() {
342                writeln!(md, "### Introduced Vulnerabilities\n")?;
343                writeln!(
344                    md,
345                    "| ID | Severity | CVSS | KEV | EPSS | SLA | Type | Component | Version | VEX |"
346                )?;
347                writeln!(
348                    md,
349                    "|----|----------|------|-----|------|-----|------|-----------|---------|-----|"
350                )?;
351                for vuln in &result.vulnerabilities.introduced {
352                    let depth_label = match vuln.component_depth {
353                        Some(1) => "Direct",
354                        Some(_) => "Transitive",
355                        None => "-",
356                    };
357                    let sla_display = format_sla_display(vuln);
358                    let vex_display = format_vex_display(vuln.vex_state.as_ref());
359                    let kev_display = if vuln.is_kev { "⚠ KEV" } else { "-" };
360                    let epss_display = format_epss_display(vuln.epss_score);
361                    writeln!(
362                        md,
363                        "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
364                        escape_markdown_table(&vuln.id),
365                        escape_markdown_table(&vuln.severity),
366                        vuln.cvss_score
367                            .map(|s| format!("{s:.1}"))
368                            .as_deref()
369                            .unwrap_or("-"),
370                        kev_display,
371                        epss_display,
372                        escape_markdown_table(&sla_display),
373                        depth_label,
374                        escape_markdown_table(&vuln.component_name),
375                        escape_md_opt(vuln.version.as_deref()),
376                        vex_display,
377                    )?;
378                }
379                writeln!(md)?;
380            }
381
382            if !result.vulnerabilities.resolved.is_empty() {
383                writeln!(md, "### Resolved Vulnerabilities\n")?;
384                writeln!(md, "| ID | Severity | SLA | Type | Component | VEX |")?;
385                writeln!(md, "|----|----------|-----|------|-----------|-----|")?;
386                for vuln in &result.vulnerabilities.resolved {
387                    let depth_label = match vuln.component_depth {
388                        Some(1) => "Direct",
389                        Some(_) => "Transitive",
390                        None => "-",
391                    };
392                    let sla_display = format_sla_display(vuln);
393                    let vex_display = format_vex_display(vuln.vex_state.as_ref());
394                    writeln!(
395                        md,
396                        "| {} | {} | {} | {} | {} | {} |",
397                        escape_markdown_table(&vuln.id),
398                        escape_markdown_table(&vuln.severity),
399                        escape_markdown_table(&sla_display),
400                        depth_label,
401                        escape_markdown_table(&vuln.component_name),
402                        vex_display,
403                    )?;
404                }
405                writeln!(md)?;
406            }
407        }
408
409        // VEX coverage summary (if any vulns have VEX data)
410        {
411            let vex_summary = result.vulnerabilities.vex_summary();
412            if vex_summary.total_vulns > 0 {
413                writeln!(md, "### VEX Coverage\n")?;
414                writeln!(md, "| Metric | Value |")?;
415                writeln!(md, "|--------|-------|")?;
416                writeln!(
417                    md,
418                    "| Coverage | {:.1}% ({}/{}) |",
419                    vex_summary.coverage_pct, vex_summary.with_vex, vex_summary.total_vulns
420                )?;
421                writeln!(md, "| Actionable | {} |", vex_summary.actionable)?;
422                for (state, count) in &vex_summary.by_state {
423                    writeln!(md, "| {state} | {count} |")?;
424                }
425                writeln!(md)?;
426            }
427        }
428
429        // Graph changes section
430        if let Some(ref summary) = result.graph_summary
431            && summary.total_changes > 0
432        {
433            writeln!(md, "## Graph Changes\n")?;
434            writeln!(md, "| Type | Count |")?;
435            writeln!(md, "|------|-------|")?;
436            writeln!(
437                md,
438                "| Dependencies Added | {} |",
439                summary.dependencies_added
440            )?;
441            writeln!(
442                md,
443                "| Dependencies Removed | {} |",
444                summary.dependencies_removed
445            )?;
446            writeln!(
447                md,
448                "| Relationship Changed | {} |",
449                summary.relationship_changed
450            )?;
451            writeln!(md, "| Reparented | {} |", summary.reparented)?;
452            writeln!(md, "| Depth Changed | {} |", summary.depth_changed)?;
453            writeln!(md, "| **Total** | **{}** |", summary.total_changes)?;
454            writeln!(md)?;
455
456            // Detailed graph changes table
457            if !result.graph_changes.is_empty() {
458                writeln!(md, "### Graph Change Details\n")?;
459                writeln!(md, "| Impact | Type | Component | Details |")?;
460                writeln!(md, "|--------|------|-----------|---------|")?;
461                for change in &result.graph_changes {
462                    let impact = change.impact.as_str().to_uppercase();
463                    let (change_type, details) = match &change.change {
464                        crate::diff::DependencyChangeType::DependencyAdded {
465                            dependency_name,
466                            ..
467                        } => ("Added", format!("+ {dependency_name}")),
468                        crate::diff::DependencyChangeType::DependencyRemoved {
469                            dependency_name,
470                            ..
471                        } => ("Removed", format!("- {dependency_name}")),
472                        crate::diff::DependencyChangeType::RelationshipChanged {
473                            dependency_name,
474                            old_relationship,
475                            new_relationship,
476                            ..
477                        } => (
478                            "Rel Changed",
479                            format!("{dependency_name}: {old_relationship} → {new_relationship}"),
480                        ),
481                        crate::diff::DependencyChangeType::Reparented {
482                            old_parent_name,
483                            new_parent_name,
484                            ..
485                        } => (
486                            "Reparented",
487                            format!("{old_parent_name} → {new_parent_name}"),
488                        ),
489                        crate::diff::DependencyChangeType::DepthChanged {
490                            old_depth,
491                            new_depth,
492                        } => {
493                            let od = if *old_depth == u32::MAX {
494                                "unreachable".to_string()
495                            } else {
496                                old_depth.to_string()
497                            };
498                            let nd = if *new_depth == u32::MAX {
499                                "unreachable".to_string()
500                            } else {
501                                new_depth.to_string()
502                            };
503                            ("Depth", format!("{od} → {nd}"))
504                        }
505                    };
506                    writeln!(
507                        md,
508                        "| {} | {} | {} | {} |",
509                        escape_markdown_table(&impact),
510                        change_type,
511                        escape_markdown_table(&change.component_name),
512                        escape_markdown_table(&details),
513                    )?;
514                }
515                writeln!(md)?;
516            }
517
518            if summary.by_impact.critical > 0 || summary.by_impact.high > 0 {
519                writeln!(md, "### Impact Summary\n")?;
520                writeln!(md, "| Impact | Count |")?;
521                writeln!(md, "|--------|-------|")?;
522                if summary.by_impact.critical > 0 {
523                    writeln!(md, "| Critical | {} |", summary.by_impact.critical)?;
524                }
525                if summary.by_impact.high > 0 {
526                    writeln!(md, "| High | {} |", summary.by_impact.high)?;
527                }
528                if summary.by_impact.medium > 0 {
529                    writeln!(md, "| Medium | {} |", summary.by_impact.medium)?;
530                }
531                if summary.by_impact.low > 0 {
532                    writeln!(md, "| Low | {} |", summary.by_impact.low)?;
533                }
534                writeln!(md)?;
535            }
536        }
537
538        // End-of-Life section (from new SBOM)
539        {
540            let eol_components: Vec<_> = new_sbom
541                .components
542                .values()
543                .filter(|c| {
544                    c.eol.as_ref().is_some_and(|e| {
545                        matches!(
546                            e.status,
547                            crate::model::EolStatus::EndOfLife
548                                | crate::model::EolStatus::ApproachingEol
549                        )
550                    })
551                })
552                .collect();
553
554            if !eol_components.is_empty() {
555                writeln!(md, "## End-of-Life Components\n")?;
556                writeln!(md, "| Component | Version | Status | Product | EOL Date |")?;
557                writeln!(md, "|-----------|---------|--------|---------|----------|")?;
558                for comp in &eol_components {
559                    let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
560                    writeln!(
561                        md,
562                        "| {} | {} | {} | {} | {} |",
563                        escape_markdown_table(&comp.name),
564                        escape_md_opt(comp.version.as_deref()),
565                        escape_markdown_table(eol.status.label()),
566                        escape_markdown_table(&eol.product),
567                        eol.eol_date
568                            .map_or_else(|| "-".to_string(), |d| d.to_string()),
569                    )?;
570                }
571                writeln!(md)?;
572            }
573        }
574
575        // CRA Compliance section
576        {
577            let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
578                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
579            });
580            let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
581                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
582            });
583            write_cra_compliance_diff(&mut md, &old_cra, &new_cra)?;
584        }
585
586        // Footer
587        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        // Title
601        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        // Metadata
608        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        // Summary
616        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        // Components
631        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.expression));
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        // Cryptographic Inventory section (only if crypto components exist)
664        {
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                // Quantum readiness summary
724                let metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
725                if metrics.algorithms_count > 0 {
726                    writeln!(
727                        md,
728                        "\n**Quantum Readiness:** {:.0}% ({} safe / {} total algorithms)",
729                        metrics.quantum_readiness_score(),
730                        metrics.quantum_safe_count,
731                        metrics.algorithms_count,
732                    )?;
733                }
734                if !metrics.weak_algorithm_names.is_empty() {
735                    writeln!(
736                        md,
737                        "\n**Weak Algorithms:** {}",
738                        metrics.weak_algorithm_names.join(", ")
739                    )?;
740                }
741            }
742        }
743
744        // CRA Compliance section
745        {
746            let cra = config
747                .view_cra_compliance
748                .clone()
749                .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
750            write_cra_compliance_view(&mut md, &cra)?;
751        }
752
753        Ok(md)
754    }
755
756    fn format(&self) -> ReportFormat {
757        ReportFormat::Markdown
758    }
759}
760
761/// Format a delta indicator: arrow showing improvement/regression
762fn delta_indicator(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
763    if old_val == new_val {
764        ""
765    } else if (new_val < old_val) == lower_is_better {
766        " (+)" // improvement
767    } else {
768        " (!)" // regression
769    }
770}
771
772/// Compute compliance score as percentage (0-100)
773fn compliance_score(result: &ComplianceResult) -> u8 {
774    let total = result.violations.len() + 1; // +1 avoids div-by-zero, counts "base pass"
775    let issues = result.error_count + result.warning_count;
776    let score = if issues >= total {
777        0
778    } else {
779        ((total - issues) * 100) / total
780    };
781    score.min(100) as u8
782}
783
784/// Write CRA compliance comparison for diff reports
785fn write_cra_compliance_diff(
786    md: &mut String,
787    old: &ComplianceResult,
788    new: &ComplianceResult,
789) -> std::fmt::Result {
790    writeln!(md, "## CRA Compliance\n")?;
791
792    // Status summary with delta indicators
793    let old_status = if old.is_compliant {
794        "Compliant"
795    } else {
796        "Non-compliant"
797    };
798    let new_status = if new.is_compliant {
799        "Compliant"
800    } else {
801        "Non-compliant"
802    };
803    let old_score = compliance_score(old);
804    let new_score = compliance_score(new);
805    let err_delta = delta_indicator(old.error_count, new.error_count, true);
806    let warn_delta = delta_indicator(old.warning_count, new.warning_count, true);
807    let score_delta = delta_indicator(old_score.into(), new_score.into(), false);
808
809    writeln!(md, "| | Old SBOM | New SBOM | Trend |")?;
810    writeln!(md, "|--|----------|----------|-------|")?;
811    writeln!(md, "| **Status** | {old_status} | {new_status} | |")?;
812    writeln!(
813        md,
814        "| **Score** | {old_score}% | {new_score}% | {score_delta} |"
815    )?;
816    writeln!(
817        md,
818        "| **Level** | {} | {} | |",
819        old.level.name(),
820        new.level.name()
821    )?;
822    writeln!(
823        md,
824        "| **Errors** | {} | {} | {err_delta} |",
825        old.error_count, new.error_count
826    )?;
827    writeln!(
828        md,
829        "| **Warnings** | {} | {} | {warn_delta} |",
830        old.warning_count, new.warning_count
831    )?;
832    writeln!(md)?;
833
834    // Conformity assessment (CRA Annex VIII) — only when product class pinned
835    write_conformity_assessment_md(md, new)?;
836    // Reporting channels (CRA Art. 14) — derived from new SBOM violations
837    write_reporting_channels_md(md, new)?;
838
839    write_compact_diff_violation_summary(md, new)?;
840
841    Ok(())
842}
843
844fn write_compact_diff_violation_summary(
845    md: &mut String,
846    result: &ComplianceResult,
847) -> std::fmt::Result {
848    if result.violations.is_empty() {
849        return Ok(());
850    }
851
852    let group_count = count_violation_groups(&result.violations);
853    writeln!(md, "### Violation Summary (New SBOM)\n")?;
854    writeln!(
855        md,
856        "- {} total findings across {group_count} distinct requirement groups.",
857        result.violations.len(),
858    )?;
859    writeln!(
860        md,
861        "- Re-run with `sbom-tools diff ... -o json` or `-o sarif` for the full CRA violation detail.\n"
862    )?;
863
864    Ok(())
865}
866
867/// Write CRA compliance for view reports
868fn write_cra_compliance_view(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
869    writeln!(md, "## CRA Compliance\n")?;
870
871    let status = if result.is_compliant {
872        "Compliant"
873    } else {
874        "Non-compliant"
875    };
876    let score = compliance_score(result);
877    writeln!(md, "**Status:** {status}  ")?;
878    writeln!(md, "**Score:** {score}%  ")?;
879    writeln!(md, "**Level:** {}  ", result.level.name())?;
880    writeln!(
881        md,
882        "**Issues:** {} errors, {} warnings\n",
883        result.error_count, result.warning_count
884    )?;
885
886    write_conformity_assessment_md(md, result)?;
887    write_reporting_channels_md(md, result)?;
888
889    if !result.violations.is_empty() {
890        write_violation_table(md, &result.violations)?;
891    }
892
893    Ok(())
894}
895
896/// Render a "Conformity assessment" subsection (CRA-P4.3) when a product
897/// class has been pinned. Surfaces the resolved Annex VIII route and a
898/// per-route evidence checklist.
899fn write_conformity_assessment_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
900    let Some(summary) = result.conformity_summary.as_ref() else {
901        return Ok(());
902    };
903    writeln!(md, "### Conformity Assessment (CRA Annex VIII)\n")?;
904    writeln!(md, "- **Product class:** {}", summary.product_class.name())?;
905    writeln!(md, "- **Conformity route:** {}\n", summary.route.name())?;
906    writeln!(md, "| Evidence | Status | Detail |")?;
907    writeln!(md, "|----------|--------|--------|")?;
908    for ev in &summary.evidence {
909        let status = if ev.satisfied {
910            "✅ Present"
911        } else {
912            "❌ Missing"
913        };
914        writeln!(md, "| {} | {} | {} |", ev.label, status, ev.detail)?;
915    }
916    writeln!(md)?;
917    Ok(())
918}
919
920/// Render a "Reporting channels" subsection (CRA Art. 14 readiness).
921///
922/// We can't see the raw sidecar from here, so the status of each channel is
923/// derived from the violation pattern produced by `check_article_14_readiness`:
924/// - violation absent → channel documented (only meaningful for CRA levels)
925/// - violation at Info → pre-deadline, channel still missing
926/// - violation at Warning → post-deadline, channel missing
927///
928/// Skipped entirely for non-CRA levels.
929fn write_reporting_channels_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
930    if !result.level.is_cra() {
931        return Ok(());
932    }
933
934    let psirt = channel_status(result, "Art. 14: PSIRT");
935    let early = channel_status(result, "Art. 14(1)");
936    let incident = channel_status(result, "Art. 14(2)");
937    let enisa = channel_status(result, "Art. 14(7)");
938
939    writeln!(md, "### Reporting Channels (CRA Art. 14)\n")?;
940    writeln!(md, "| Channel | Status |")?;
941    writeln!(md, "|---------|--------|")?;
942    writeln!(md, "| PSIRT contact | {} |", psirt.label())?;
943    writeln!(
944        md,
945        "| 24-hour early warning (Art. 14(1)) | {} |",
946        early.label()
947    )?;
948    writeln!(
949        md,
950        "| 72-hour incident report (Art. 14(2)) | {} |",
951        incident.label()
952    )?;
953    writeln!(
954        md,
955        "| ENISA single reporting platform (Art. 14(7)) | {} |",
956        enisa.label()
957    )?;
958    writeln!(md)?;
959    writeln!(
960        md,
961        "_Article 14 reporting obligations apply from 11 September 2026. \
962         Channels marked 'Missing (pre-deadline)' surface as Info; \
963         after the deadline they become Warnings._\n"
964    )?;
965    Ok(())
966}
967
968fn channel_status(result: &ComplianceResult, needle: &str) -> ChannelStatus {
969    match result
970        .violations
971        .iter()
972        .find(|v| v.requirement.contains(needle))
973    {
974        None => ChannelStatus::Documented,
975        Some(v) => match v.severity {
976            ViolationSeverity::Warning | ViolationSeverity::Error => {
977                ChannelStatus::MissingPostDeadline
978            }
979            ViolationSeverity::Info => ChannelStatus::MissingPreDeadline,
980        },
981    }
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
985enum ChannelStatus {
986    Documented,
987    MissingPreDeadline,
988    MissingPostDeadline,
989}
990
991impl ChannelStatus {
992    fn label(self) -> &'static str {
993        match self {
994            Self::Documented => "Documented",
995            Self::MissingPreDeadline => "Missing (pre-deadline 2026-09-11)",
996            Self::MissingPostDeadline => "Missing",
997        }
998    }
999}
1000
1001/// Count distinct `(severity, category, requirement)` violation groups without
1002/// allocating the full aggregated representation.
1003fn count_violation_groups(violations: &[crate::quality::Violation]) -> usize {
1004    use std::collections::HashSet;
1005    let mut groups: HashSet<(u8, &str, &str)> = HashSet::new();
1006    for v in violations {
1007        let sev_ord = match v.severity {
1008            ViolationSeverity::Error => 0,
1009            ViolationSeverity::Warning => 1,
1010            ViolationSeverity::Info => 2,
1011        };
1012        groups.insert((sev_ord, v.category.name(), v.requirement.as_str()));
1013    }
1014    groups.len()
1015}
1016
1017/// Aggregate violations by (severity, category, requirement) to reduce noise.
1018/// Per-component violations like "Component X missing supplier" x9 become
1019/// "9 components missing supplier (X, Y, Z, ...)".
1020fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
1021    use std::collections::BTreeMap;
1022
1023    // Group by (severity, category, requirement)
1024    let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
1025    for v in violations {
1026        let sev_ord = match v.severity {
1027            ViolationSeverity::Error => 0,
1028            ViolationSeverity::Warning => 1,
1029            ViolationSeverity::Info => 2,
1030        };
1031        groups
1032            .entry((sev_ord, v.category.name(), v.requirement.as_str()))
1033            .or_default()
1034            .push(v);
1035    }
1036
1037    groups
1038        .into_values()
1039        .map(|group| {
1040            let standard_refs = format_standard_refs(&group[0].standard_refs);
1041            if group.len() == 1 {
1042                AggregatedViolation {
1043                    severity: group[0].severity,
1044                    category: group[0].category.name(),
1045                    requirement: &group[0].requirement,
1046                    message: group[0].message.clone(),
1047                    remediation: group[0].remediation_guidance(),
1048                    count: 1,
1049                    standard_refs,
1050                }
1051            } else {
1052                let elements: Vec<&str> =
1053                    group.iter().filter_map(|v| v.element.as_deref()).collect();
1054                let message = if elements.is_empty() {
1055                    group[0].message.clone()
1056                } else {
1057                    let preview: Vec<&str> = elements.iter().take(5).copied().collect();
1058                    let suffix = if elements.len() > 5 {
1059                        format!(", ... +{} more", elements.len() - 5)
1060                    } else {
1061                        String::new()
1062                    };
1063                    format!(
1064                        "{} components affected ({}{})",
1065                        elements.len(),
1066                        preview.join(", "),
1067                        suffix
1068                    )
1069                };
1070                AggregatedViolation {
1071                    severity: group[0].severity,
1072                    category: group[0].category.name(),
1073                    requirement: &group[0].requirement,
1074                    message,
1075                    remediation: group[0].remediation_guidance(),
1076                    count: group.len(),
1077                    standard_refs,
1078                }
1079            }
1080        })
1081        .collect()
1082}
1083
1084struct AggregatedViolation<'a> {
1085    severity: ViolationSeverity,
1086    category: &'a str,
1087    requirement: &'a str,
1088    message: String,
1089    remediation: &'static str,
1090    count: usize,
1091    /// Pre-rendered "Standard refs" cell — `<kind>:<id>` joined by `, `.
1092    /// Empty when the violation has no derived refs.
1093    standard_refs: String,
1094}
1095
1096/// Render a `Vec<StandardRef>` as a compact, table-safe cell.
1097fn format_standard_refs(refs: &[crate::quality::StandardRef]) -> String {
1098    use std::fmt::Write;
1099    let mut out = String::new();
1100    for (i, r) in refs.iter().enumerate() {
1101        if i > 0 {
1102            out.push_str(", ");
1103        }
1104        let _ = write!(out, "{}: {}", r.standard.label(), r.id);
1105    }
1106    out
1107}
1108
1109/// Write a markdown table of CRA compliance violations (aggregated)
1110fn write_violation_table(
1111    md: &mut String,
1112    violations: &[crate::quality::Violation],
1113) -> std::fmt::Result {
1114    let aggregated = aggregate_violations(violations);
1115    writeln!(
1116        md,
1117        "| Severity | Category | Standard refs | Requirement | Message | Remediation |"
1118    )?;
1119    writeln!(
1120        md,
1121        "|----------|----------|---------------|-------------|---------|-------------|"
1122    )?;
1123    for v in &aggregated {
1124        let severity = match v.severity {
1125            ViolationSeverity::Error => "Error",
1126            ViolationSeverity::Warning => "Warning",
1127            ViolationSeverity::Info => "Info",
1128        };
1129        let count_suffix = if v.count > 1 {
1130            format!(" (x{})", v.count)
1131        } else {
1132            String::new()
1133        };
1134        writeln!(
1135            md,
1136            "| {}{} | {} | {} | {} | {} | {} |",
1137            severity,
1138            escape_markdown_table(&count_suffix),
1139            escape_markdown_table(v.category),
1140            escape_markdown_table(&v.standard_refs),
1141            escape_markdown_table(v.requirement),
1142            escape_markdown_table(&v.message),
1143            escape_markdown_table(v.remediation),
1144        )?;
1145    }
1146    writeln!(md)?;
1147    Ok(())
1148}
1149
1150/// Format SLA status for display in reports
1151fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
1152    match vuln.sla_status() {
1153        SlaStatus::Overdue(days) => format!("{days}d late"),
1154        SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
1155        SlaStatus::NoDueDate => vuln
1156            .days_since_published
1157            .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
1158    }
1159}
1160
1161fn format_vex_display(vex_state: Option<&crate::model::VexState>) -> &'static str {
1162    match vex_state {
1163        Some(crate::model::VexState::NotAffected) => "Not Affected",
1164        Some(crate::model::VexState::Fixed) => "Fixed",
1165        Some(crate::model::VexState::Affected) => "Affected",
1166        Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
1167        None => "-",
1168    }
1169}
1170
1171/// Format a FIRST EPSS exploit-probability score as a percentage for reports.
1172fn format_epss_display(epss_score: Option<f64>) -> String {
1173    epss_score.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0))
1174}