1use 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
9pub struct MarkdownReporter {
11 include_toc: bool,
13}
14
15impl MarkdownReporter {
16 pub fn new() -> Self {
18 Self { include_toc: true }
19 }
20
21 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 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 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 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 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 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 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 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 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 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 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 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 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 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
431fn 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}