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::{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> =
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        // Dependency changes section
240        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        // License changes section
277        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        // Vulnerability changes section
339        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        // VEX coverage summary (if any vulns have VEX data)
414        {
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        // Graph changes section
434        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            // Detailed graph changes table
461            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        // End-of-Life section (from new SBOM)
543        {
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        // CRA Compliance section
580        {
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        // 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.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        // 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 (`None` = no algorithms, row omitted)
724                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        // CRA Compliance section
743        {
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
756/// Format a delta indicator: arrow showing improvement/regression
757fn 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        " (+)" // improvement
762    } else {
763        " (!)" // regression
764    }
765}
766
767/// Compute compliance score as percentage (0-100)
768fn compliance_score(result: &ComplianceResult) -> u8 {
769    // Diff/view reports only render CRA results, which are always
770    // applicable; 0 is a defensive fallback, not a rendered state.
771    result.score().unwrap_or(0)
772}
773
774/// Write CRA compliance comparison for diff reports
775fn 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    // Status summary with delta indicators
783    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    // Conformity assessment (CRA Annex VIII) — only when product class pinned
825    write_conformity_assessment_md(md, new)?;
826    // Reporting channels (CRA Art. 14) — derived from new SBOM violations
827    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
857/// Write CRA compliance for view reports
858fn 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
886/// Render a "Conformity assessment" subsection (CRA-P4.3) when a product
887/// class has been pinned. Surfaces the resolved Annex VIII route and a
888/// per-route evidence checklist.
889fn 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
910/// Render a "Reporting channels" subsection (CRA Art. 14 readiness).
911///
912/// We can't see the raw sidecar from here, so the status of each channel is
913/// derived from the violation pattern produced by `check_article_14_readiness`:
914/// - violation absent → channel documented (only meaningful for CRA levels)
915/// - violation at Info → pre-deadline, channel still missing
916/// - violation at Warning → post-deadline, channel missing
917///
918/// Skipped entirely for non-CRA levels.
919fn 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
991/// Count distinct `(severity, category, requirement)` violation groups without
992/// allocating the full aggregated representation.
993fn 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
1007/// Aggregate violations by (severity, category, requirement) to reduce noise.
1008/// Per-component violations like "Component X missing supplier" x9 become
1009/// "9 components missing supplier (X, Y, Z, ...)".
1010fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
1011    use std::collections::BTreeMap;
1012
1013    // Group by (severity, category, requirement)
1014    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    /// Pre-rendered "Standard refs" cell — `<kind>:<id>` joined by `, `.
1082    /// Empty when the violation has no derived refs.
1083    standard_refs: String,
1084}
1085
1086/// Render a `Vec<StandardRef>` as a compact, table-safe cell.
1087fn 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
1099/// Write a markdown table of CRA compliance violations (aggregated)
1100fn 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
1140/// Format SLA status for display in reports
1141fn 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
1161/// Format a FIRST EPSS exploit-probability score as a percentage for reports.
1162fn format_epss_display(epss_score: Option<f64>) -> String {
1163    epss_score.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0))
1164}