Skip to main content

sbom_tools/reports/
markdown.rs

1//! Markdown report generator.
2
3use super::escape::{escape_markdown_inline, escape_markdown_list, escape_markdown_table, escape_md_opt};
4use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
5use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
6use crate::model::NormalizedSbom;
7use std::fmt::Write;
8
9/// Markdown report generator
10pub struct MarkdownReporter {
11    /// Include table of contents
12    include_toc: bool,
13}
14
15impl MarkdownReporter {
16    /// Create a new Markdown reporter
17    pub fn new() -> Self {
18        Self { include_toc: true }
19    }
20
21    /// Set whether to include table of contents
22    pub fn include_toc(mut self, include: bool) -> Self {
23        self.include_toc = include;
24        self
25    }
26}
27
28impl Default for MarkdownReporter {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ReportGenerator for MarkdownReporter {
35    fn generate_diff_report(
36        &self,
37        result: &DiffResult,
38        old_sbom: &NormalizedSbom,
39        new_sbom: &NormalizedSbom,
40        config: &ReportConfig,
41    ) -> Result<String, ReportError> {
42        let mut md = String::new();
43
44        // Title
45        let title = config
46            .title
47            .clone()
48            .unwrap_or_else(|| "SBOM Diff Report".to_string());
49        writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
50
51        // Metadata
52        writeln!(
53            md,
54            "**Generated by:** sbom-tools v{}",
55            env!("CARGO_PKG_VERSION")
56        )?;
57        writeln!(
58            md,
59            "**Date:** {}\n",
60            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
61        )?;
62
63        // Table of contents
64        if self.include_toc {
65            writeln!(md, "## Table of Contents\n")?;
66            writeln!(md, "- [Summary](#summary)")?;
67            if config.includes(ReportType::Components) {
68                writeln!(md, "- [Component Changes](#component-changes)")?;
69            }
70            if config.includes(ReportType::Dependencies) {
71                writeln!(md, "- [Dependency Changes](#dependency-changes)")?;
72            }
73            if config.includes(ReportType::Licenses) {
74                writeln!(md, "- [License Changes](#license-changes)")?;
75            }
76            if config.includes(ReportType::Vulnerabilities) {
77                writeln!(md, "- [Vulnerability Changes](#vulnerability-changes)")?;
78            }
79            writeln!(md)?;
80        }
81
82        // Summary section
83        writeln!(md, "## Summary\n")?;
84        writeln!(md, "| Metric | Old SBOM | New SBOM |")?;
85        writeln!(md, "|--------|----------|----------|")?;
86        writeln!(
87            md,
88            "| **Format** | {} | {} |",
89            old_sbom.document.format, new_sbom.document.format
90        )?;
91        writeln!(
92            md,
93            "| **Components** | {} | {} |",
94            old_sbom.component_count(),
95            new_sbom.component_count()
96        )?;
97        writeln!(
98            md,
99            "| **Dependencies** | {} | {} |",
100            old_sbom.edges.len(),
101            new_sbom.edges.len()
102        )?;
103        writeln!(md)?;
104
105        writeln!(md, "### Change Summary\n")?;
106        writeln!(md, "| Category | Count |")?;
107        writeln!(md, "|----------|-------|")?;
108        writeln!(
109            md,
110            "| Components Added | {} |",
111            result.summary.components_added
112        )?;
113        writeln!(
114            md,
115            "| Components Removed | {} |",
116            result.summary.components_removed
117        )?;
118        writeln!(
119            md,
120            "| Components Modified | {} |",
121            result.summary.components_modified
122        )?;
123        writeln!(
124            md,
125            "| Vulnerabilities Introduced | {} |",
126            result.summary.vulnerabilities_introduced
127        )?;
128        writeln!(
129            md,
130            "| Vulnerabilities Resolved | {} |",
131            result.summary.vulnerabilities_resolved
132        )?;
133        writeln!(md, "| **Semantic Score** | {:.1} |", result.semantic_score)?;
134        writeln!(md)?;
135
136        // Component changes section
137        if config.includes(ReportType::Components) {
138            writeln!(md, "## Component Changes\n")?;
139
140            if !result.components.added.is_empty() {
141                writeln!(md, "### Added Components\n")?;
142                writeln!(md, "| Name | Version | Ecosystem |")?;
143                writeln!(md, "|------|---------|-----------|")?;
144                for comp in &result.components.added {
145                    writeln!(
146                        md,
147                        "| {} | {} | {} |",
148                        escape_markdown_table(&comp.name),
149                        escape_md_opt(comp.new_version.as_deref()),
150                        escape_md_opt(comp.ecosystem.as_deref())
151                    )?;
152                }
153                writeln!(md)?;
154            }
155
156            if !result.components.removed.is_empty() {
157                writeln!(md, "### Removed Components\n")?;
158                writeln!(md, "| Name | Version | Ecosystem |")?;
159                writeln!(md, "|------|---------|-----------|")?;
160                for comp in &result.components.removed {
161                    writeln!(
162                        md,
163                        "| {} | {} | {} |",
164                        escape_markdown_table(&comp.name),
165                        escape_md_opt(comp.old_version.as_deref()),
166                        escape_md_opt(comp.ecosystem.as_deref())
167                    )?;
168                }
169                writeln!(md)?;
170            }
171
172            if !result.components.modified.is_empty() {
173                writeln!(md, "### Modified Components\n")?;
174                writeln!(md, "| Name | Old Version | New Version | Changes |")?;
175                writeln!(md, "|------|-------------|-------------|---------|")?;
176                for comp in &result.components.modified {
177                    let changes: Vec<String> = comp
178                        .field_changes
179                        .iter()
180                        .map(|c| escape_markdown_table(&c.field))
181                        .collect();
182                    writeln!(
183                        md,
184                        "| {} | {} | {} | {} |",
185                        escape_markdown_table(&comp.name),
186                        escape_md_opt(comp.old_version.as_deref()),
187                        escape_md_opt(comp.new_version.as_deref()),
188                        changes.join(", ")
189                    )?;
190                }
191                writeln!(md)?;
192            }
193        }
194
195        // Dependency changes section
196        if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
197            writeln!(md, "## Dependency Changes\n")?;
198
199            if !result.dependencies.added.is_empty() {
200                writeln!(md, "### Added Dependencies\n")?;
201                writeln!(md, "| From | To | Relationship |")?;
202                writeln!(md, "|------|----|--------------|")?;
203                for dep in &result.dependencies.added {
204                    writeln!(
205                        md,
206                        "| {} | {} | {} |",
207                        escape_markdown_table(&dep.from),
208                        escape_markdown_table(&dep.to),
209                        escape_markdown_table(&dep.relationship)
210                    )?;
211                }
212                writeln!(md)?;
213            }
214
215            if !result.dependencies.removed.is_empty() {
216                writeln!(md, "### Removed Dependencies\n")?;
217                writeln!(md, "| From | To | Relationship |")?;
218                writeln!(md, "|------|----|--------------|")?;
219                for dep in &result.dependencies.removed {
220                    writeln!(
221                        md,
222                        "| {} | {} | {} |",
223                        escape_markdown_table(&dep.from),
224                        escape_markdown_table(&dep.to),
225                        escape_markdown_table(&dep.relationship)
226                    )?;
227                }
228                writeln!(md)?;
229            }
230        }
231
232        // License changes section
233        if config.includes(ReportType::Licenses) {
234            writeln!(md, "## License Changes\n")?;
235
236            if !result.licenses.new_licenses.is_empty() {
237                writeln!(md, "### New Licenses\n")?;
238                for lic in &result.licenses.new_licenses {
239                    let escaped_components: Vec<String> = lic
240                        .components
241                        .iter()
242                        .map(|c| escape_markdown_list(c))
243                        .collect();
244                    writeln!(
245                        md,
246                        "- **{}**: {}",
247                        escape_markdown_list(&lic.license),
248                        escaped_components.join(", ")
249                    )?;
250                }
251                writeln!(md)?;
252            }
253
254            if !result.licenses.removed_licenses.is_empty() {
255                writeln!(md, "### Removed Licenses\n")?;
256                for lic in &result.licenses.removed_licenses {
257                    let escaped_components: Vec<String> = lic
258                        .components
259                        .iter()
260                        .map(|c| escape_markdown_list(c))
261                        .collect();
262                    writeln!(
263                        md,
264                        "- **{}**: {}",
265                        escape_markdown_list(&lic.license),
266                        escaped_components.join(", ")
267                    )?;
268                }
269                writeln!(md)?;
270            }
271
272            if !result.licenses.conflicts.is_empty() {
273                writeln!(md, "### License Conflicts\n")?;
274                writeln!(md, "| License A | License B | Component | Description |")?;
275                writeln!(md, "|-----------|-----------|-----------|-------------|")?;
276                for conflict in &result.licenses.conflicts {
277                    writeln!(
278                        md,
279                        "| {} | {} | {} | {} |",
280                        escape_markdown_table(&conflict.license_a),
281                        escape_markdown_table(&conflict.license_b),
282                        escape_markdown_table(&conflict.component),
283                        escape_markdown_table(&conflict.description)
284                    )?;
285                }
286                writeln!(md)?;
287            }
288        }
289
290        // Vulnerability changes section
291        if config.includes(ReportType::Vulnerabilities) {
292            writeln!(md, "## Vulnerability Changes\n")?;
293
294            if !result.vulnerabilities.introduced.is_empty() {
295                writeln!(md, "### Introduced Vulnerabilities\n")?;
296                writeln!(md, "| ID | Severity | CVSS | SLA | Type | Component | Version |")?;
297                writeln!(md, "|----|----------|------|-----|------|-----------|---------|")?;
298                for vuln in &result.vulnerabilities.introduced {
299                    let depth_label = match vuln.component_depth {
300                        Some(1) => "Direct",
301                        Some(_) => "Transitive",
302                        None => "-",
303                    };
304                    let sla_display = format_sla_display(vuln);
305                    writeln!(
306                        md,
307                        "| {} | {} | {} | {} | {} | {} | {} |",
308                        escape_markdown_table(&vuln.id),
309                        escape_markdown_table(&vuln.severity),
310                        vuln.cvss_score
311                            .map(|s| format!("{:.1}", s))
312                            .unwrap_or_else(|| "-".to_string()),
313                        escape_markdown_table(&sla_display),
314                        depth_label,
315                        escape_markdown_table(&vuln.component_name),
316                        escape_md_opt(vuln.version.as_deref())
317                    )?;
318                }
319                writeln!(md)?;
320            }
321
322            if !result.vulnerabilities.resolved.is_empty() {
323                writeln!(md, "### Resolved Vulnerabilities\n")?;
324                writeln!(md, "| ID | Severity | SLA | Type | Component |")?;
325                writeln!(md, "|----|----------|-----|------|-----------|")?;
326                for vuln in &result.vulnerabilities.resolved {
327                    let depth_label = match vuln.component_depth {
328                        Some(1) => "Direct",
329                        Some(_) => "Transitive",
330                        None => "-",
331                    };
332                    let sla_display = format_sla_display(vuln);
333                    writeln!(
334                        md,
335                        "| {} | {} | {} | {} | {} |",
336                        escape_markdown_table(&vuln.id),
337                        escape_markdown_table(&vuln.severity),
338                        escape_markdown_table(&sla_display),
339                        depth_label,
340                        escape_markdown_table(&vuln.component_name)
341                    )?;
342                }
343                writeln!(md)?;
344            }
345        }
346
347        // Footer
348        writeln!(md, "---\n")?;
349        writeln!(md, "*Generated by sbom-tools*")?;
350
351        Ok(md)
352    }
353
354    fn generate_view_report(
355        &self,
356        sbom: &NormalizedSbom,
357        config: &ReportConfig,
358    ) -> Result<String, ReportError> {
359        let mut md = String::new();
360
361        // Title
362        let title = config
363            .title
364            .clone()
365            .unwrap_or_else(|| "SBOM Report".to_string());
366        writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
367
368        // Metadata
369        writeln!(md, "**Format:** {}", sbom.document.format)?;
370        writeln!(md, "**Version:** {}", sbom.document.format_version)?;
371        if let Some(name) = &sbom.document.name {
372            writeln!(md, "**Name:** {}", escape_markdown_inline(name))?;
373        }
374        writeln!(md)?;
375
376        // Summary
377        writeln!(md, "## Summary\n")?;
378        writeln!(md, "| Metric | Value |")?;
379        writeln!(md, "|--------|-------|")?;
380        writeln!(md, "| Total Components | {} |", sbom.component_count())?;
381        writeln!(md, "| Total Dependencies | {} |", sbom.edges.len())?;
382
383        let vuln_counts = sbom.vulnerability_counts();
384        writeln!(md, "| Total Vulnerabilities | {} |", vuln_counts.total())?;
385        writeln!(md, "| Critical | {} |", vuln_counts.critical)?;
386        writeln!(md, "| High | {} |", vuln_counts.high)?;
387        writeln!(md, "| Medium | {} |", vuln_counts.medium)?;
388        writeln!(md, "| Low | {} |", vuln_counts.low)?;
389        writeln!(md)?;
390
391        // Components
392        writeln!(md, "## Components\n")?;
393        writeln!(
394            md,
395            "| Name | Version | Ecosystem | License | Vulnerabilities |"
396        )?;
397        writeln!(
398            md,
399            "|------|---------|-----------|---------|-----------------|"
400        )?;
401
402        for comp in sbom.components.values() {
403            let license = comp
404                .licenses
405                .declared
406                .first()
407                .map(|l| escape_markdown_table(&l.expression))
408                .unwrap_or_else(|| "-".to_string());
409            writeln!(
410                md,
411                "| {} | {} | {} | {} | {} |",
412                escape_markdown_table(&comp.name),
413                escape_md_opt(comp.version.as_deref()),
414                comp.ecosystem
415                    .as_ref()
416                    .map(|e| escape_markdown_table(&e.to_string()))
417                    .unwrap_or_else(|| "-".to_string()),
418                license,
419                comp.vulnerabilities.len()
420            )?;
421        }
422
423        Ok(md)
424    }
425
426    fn format(&self) -> ReportFormat {
427        ReportFormat::Markdown
428    }
429}
430
431/// Format SLA status for display in reports
432fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
433    match vuln.sla_status() {
434        SlaStatus::Overdue(days) => format!("{}d late", days),
435        SlaStatus::DueSoon(days) => format!("{}d left", days),
436        SlaStatus::OnTrack(days) => format!("{}d left", days),
437        SlaStatus::NoDueDate => vuln
438            .days_since_published
439            .map(|d| format!("{}d old", d))
440            .unwrap_or_else(|| "-".to_string()),
441    }
442}