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