1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::DiffResult;
5use crate::model::NormalizedSbom;
6use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
7use chrono::Utc;
8use serde::Serialize;
9
10pub struct JsonReporter {
12 summary_only: bool,
14 pretty: bool,
16}
17
18impl JsonReporter {
19 #[must_use]
21 pub const fn new() -> Self {
22 Self {
23 summary_only: false,
24 pretty: true,
25 }
26 }
27
28 #[must_use]
30 pub const fn summary_only() -> Self {
31 Self {
32 summary_only: true,
33 pretty: true,
34 }
35 }
36
37 #[must_use]
39 pub const fn pretty(mut self, pretty: bool) -> Self {
40 self.pretty = pretty;
41 self
42 }
43}
44
45impl Default for JsonReporter {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl ReportGenerator for JsonReporter {
52 fn generate_diff_report(
53 &self,
54 result: &DiffResult,
55 old_sbom: &NormalizedSbom,
56 new_sbom: &NormalizedSbom,
57 config: &ReportConfig,
58 ) -> Result<String, ReportError> {
59 let old_cra = config
60 .old_cra_compliance
61 .clone()
62 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom));
63 let new_cra = config
64 .new_cra_compliance
65 .clone()
66 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom));
67 let cra_compliance = CraCompliance {
68 old: CraComplianceDetail::from_result(old_cra),
69 new: CraComplianceDetail::from_result(new_cra),
70 };
71
72 let report = JsonDiffReport {
73 metadata: JsonReportMetadata {
74 tool: ToolInfo {
75 name: "sbom-tools".to_string(),
76 version: env!("CARGO_PKG_VERSION").to_string(),
77 },
78 generated_at: Utc::now().to_rfc3339(),
79 old_sbom: SbomInfo {
80 format: old_sbom.document.format.to_string(),
81 file_path: config.metadata.old_sbom_path.clone(),
82 component_count: old_sbom.component_count(),
83 },
84 new_sbom: SbomInfo {
85 format: new_sbom.document.format.to_string(),
86 file_path: config.metadata.new_sbom_path.clone(),
87 component_count: new_sbom.component_count(),
88 },
89 },
90 summary: JsonSummary {
91 total_changes: result.summary.total_changes,
92 components: ComponentSummary {
93 added: result.summary.components_added,
94 removed: result.summary.components_removed,
95 modified: result.summary.components_modified,
96 },
97 vulnerabilities: VulnerabilitySummary {
98 introduced: result.summary.vulnerabilities_introduced,
99 resolved: result.summary.vulnerabilities_resolved,
100 persistent: result.summary.vulnerabilities_persistent,
101 },
102 semantic_score: result.semantic_score,
103 },
104 cra_compliance,
105 reports: if self.summary_only {
106 None
107 } else {
108 Some(JsonReports {
109 components: if config.includes(ReportType::Components) {
110 Some(ComponentsReport {
111 added: &result.components.added,
112 removed: &result.components.removed,
113 modified: &result.components.modified,
114 })
115 } else {
116 None
117 },
118 dependencies: if config.includes(ReportType::Dependencies) {
119 Some(DependenciesReport {
120 added: &result.dependencies.added,
121 removed: &result.dependencies.removed,
122 })
123 } else {
124 None
125 },
126 licenses: if config.includes(ReportType::Licenses) {
127 Some(LicensesReport {
128 new_licenses: &result.licenses.new_licenses,
129 removed_licenses: &result.licenses.removed_licenses,
130 conflicts: &result.licenses.conflicts,
131 })
132 } else {
133 None
134 },
135 vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
136 Some(VulnerabilitiesReport {
137 introduced: VulnerabilityWithSla::from_slice(
138 &result.vulnerabilities.introduced,
139 ),
140 resolved: VulnerabilityWithSla::from_slice(
141 &result.vulnerabilities.resolved,
142 ),
143 persistent: VulnerabilityWithSla::from_slice(
144 &result.vulnerabilities.persistent,
145 ),
146 })
147 } else {
148 None
149 },
150 })
151 },
152 };
153
154 let json = if self.pretty {
155 serde_json::to_string_pretty(&report)
156 } else {
157 serde_json::to_string(&report)
158 }
159 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
160
161 Ok(json)
162 }
163
164 fn generate_view_report(
165 &self,
166 sbom: &NormalizedSbom,
167 config: &ReportConfig,
168 ) -> Result<String, ReportError> {
169 let cra_result = config
170 .view_cra_compliance
171 .clone()
172 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
173 let compliance = CraComplianceDetail::from_result(cra_result);
174
175 let report = JsonViewReport {
176 metadata: JsonViewMetadata {
177 tool: ToolInfo {
178 name: "sbom-tools".to_string(),
179 version: env!("CARGO_PKG_VERSION").to_string(),
180 },
181 generated_at: Utc::now().to_rfc3339(),
182 sbom: SbomInfo {
183 format: sbom.document.format.to_string(),
184 file_path: config.metadata.old_sbom_path.clone(),
185 component_count: sbom.component_count(),
186 },
187 },
188 summary: ViewSummary {
189 total_components: sbom.component_count(),
190 total_dependencies: sbom.edges.len(),
191 ecosystems: sbom
192 .ecosystems()
193 .iter()
194 .map(std::string::ToString::to_string)
195 .collect(),
196 vulnerability_counts: sbom.vulnerability_counts(),
197 },
198 compliance,
199 components: sbom
200 .components
201 .values()
202 .map(|c| ComponentView {
203 name: c.name.clone(),
204 version: c.version.clone(),
205 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
206 licenses: c
207 .licenses
208 .declared
209 .iter()
210 .map(|l| l.expression.clone())
211 .collect(),
212 supplier: c.supplier.as_ref().map(|s| s.name.clone()),
213 vulnerabilities: c.vulnerabilities.len(),
214 eol_status: c.eol.as_ref().map(|e| e.status.label().to_string()),
215 eol_date: c
216 .eol
217 .as_ref()
218 .and_then(|e| e.eol_date.map(|d| d.to_string())),
219 eol_product: c.eol.as_ref().map(|e| e.product.clone()),
220 })
221 .collect(),
222 };
223
224 let json = if self.pretty {
225 serde_json::to_string_pretty(&report)
226 } else {
227 serde_json::to_string(&report)
228 }
229 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
230
231 Ok(json)
232 }
233
234 fn format(&self) -> ReportFormat {
235 ReportFormat::Json
236 }
237}
238
239#[derive(Serialize)]
242struct JsonDiffReport<'a> {
243 metadata: JsonReportMetadata,
244 summary: JsonSummary,
245 cra_compliance: CraCompliance,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 reports: Option<JsonReports<'a>>,
248}
249
250#[derive(Serialize)]
251struct CraCompliance {
252 old: CraComplianceDetail,
253 new: CraComplianceDetail,
254}
255
256#[derive(Serialize)]
257struct CraComplianceDetail {
258 #[serde(flatten)]
259 result: ComplianceResult,
260 article_summary: CraArticleSummary,
262}
263
264#[derive(Serialize)]
265struct CraArticleSummary {
266 #[serde(rename = "art_13_4_machine_readable_format")]
268 art_13_4: usize,
269 #[serde(rename = "art_13_6_vulnerability_disclosure")]
271 art_13_6: usize,
272 #[serde(rename = "art_13_7_coordinated_disclosure")]
274 art_13_7: usize,
275 #[serde(rename = "art_13_8_support_period")]
277 art_13_8: usize,
278 #[serde(rename = "art_13_11_component_lifecycle")]
280 art_13_11: usize,
281 #[serde(rename = "art_13_12_product_identification")]
283 art_13_12: usize,
284 #[serde(rename = "art_13_15_manufacturer_identification")]
286 art_13_15: usize,
287 #[serde(rename = "annex_i_technical_documentation")]
289 annex_i: usize,
290 #[serde(rename = "annex_vii_declaration_of_conformity")]
292 annex_vii: usize,
293}
294
295impl CraComplianceDetail {
296 fn from_result(result: ComplianceResult) -> Self {
297 let mut summary = CraArticleSummary {
298 art_13_4: 0,
299 art_13_6: 0,
300 art_13_7: 0,
301 art_13_8: 0,
302 art_13_11: 0,
303 art_13_12: 0,
304 art_13_15: 0,
305 annex_i: 0,
306 annex_vii: 0,
307 };
308
309 for violation in &result.violations {
311 let req = violation.requirement.to_lowercase();
312 if req.contains("art. 13(4)") || req.contains("art.13(4)") {
313 summary.art_13_4 += 1;
314 } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
315 summary.art_13_6 += 1;
316 } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
317 summary.art_13_7 += 1;
318 } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
319 summary.art_13_8 += 1;
320 } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
321 summary.art_13_11 += 1;
322 } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
323 summary.art_13_12 += 1;
324 } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
325 summary.art_13_15 += 1;
326 } else if req.contains("annex vii") {
327 summary.annex_vii += 1;
328 } else if req.contains("annex i") || req.contains("annex_i") {
329 summary.annex_i += 1;
330 }
331 }
332
333 Self {
334 result,
335 article_summary: summary,
336 }
337 }
338}
339
340#[derive(Serialize)]
341struct JsonReportMetadata {
342 tool: ToolInfo,
343 generated_at: String,
344 old_sbom: SbomInfo,
345 new_sbom: SbomInfo,
346}
347
348#[derive(Serialize)]
349struct ToolInfo {
350 name: String,
351 version: String,
352}
353
354#[derive(Serialize)]
355struct SbomInfo {
356 format: String,
357 #[serde(skip_serializing_if = "Option::is_none")]
358 file_path: Option<String>,
359 component_count: usize,
360}
361
362#[derive(Serialize)]
363struct JsonSummary {
364 total_changes: usize,
365 components: ComponentSummary,
366 vulnerabilities: VulnerabilitySummary,
367 semantic_score: f64,
368}
369
370#[derive(Serialize)]
371struct ComponentSummary {
372 added: usize,
373 removed: usize,
374 modified: usize,
375}
376
377#[derive(Serialize)]
378struct VulnerabilitySummary {
379 introduced: usize,
380 resolved: usize,
381 persistent: usize,
382}
383
384#[derive(Serialize)]
385struct JsonReports<'a> {
386 #[serde(skip_serializing_if = "Option::is_none")]
387 components: Option<ComponentsReport<'a>>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 dependencies: Option<DependenciesReport<'a>>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 licenses: Option<LicensesReport<'a>>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 vulnerabilities: Option<VulnerabilitiesReport>,
394}
395
396#[derive(Serialize)]
397struct ComponentsReport<'a> {
398 added: &'a [crate::diff::ComponentChange],
399 removed: &'a [crate::diff::ComponentChange],
400 modified: &'a [crate::diff::ComponentChange],
401}
402
403#[derive(Serialize)]
404struct DependenciesReport<'a> {
405 added: &'a [crate::diff::DependencyChange],
406 removed: &'a [crate::diff::DependencyChange],
407}
408
409#[derive(Serialize)]
410struct LicensesReport<'a> {
411 new_licenses: &'a [crate::diff::LicenseChange],
412 removed_licenses: &'a [crate::diff::LicenseChange],
413 conflicts: &'a [crate::diff::LicenseConflict],
414}
415
416#[derive(Serialize)]
417struct VulnerabilitiesReport {
418 introduced: Vec<VulnerabilityWithSla>,
419 resolved: Vec<VulnerabilityWithSla>,
420 persistent: Vec<VulnerabilityWithSla>,
421}
422
423#[derive(Serialize)]
425struct VulnerabilityWithSla {
426 #[serde(flatten)]
427 detail: crate::diff::VulnerabilityDetail,
428 sla_status: String,
429 sla_category: String,
430}
431
432impl VulnerabilityWithSla {
433 fn from_detail(v: &crate::diff::VulnerabilityDetail) -> Self {
434 let sla = v.sla_status();
435 let (status_text, category) = match &sla {
436 crate::diff::SlaStatus::Overdue(days) => (format!("{days}d overdue"), "overdue"),
437 crate::diff::SlaStatus::DueSoon(days) => (format!("{days}d remaining"), "due_soon"),
438 crate::diff::SlaStatus::OnTrack(days) => (format!("{days}d remaining"), "on_track"),
439 crate::diff::SlaStatus::NoDueDate => {
440 let text = v
441 .days_since_published
442 .map_or_else(|| "unknown".to_string(), |d| format!("{d}d old"));
443 (text, "no_due_date")
444 }
445 };
446 Self {
447 detail: v.clone(),
448 sla_status: status_text,
449 sla_category: category.to_string(),
450 }
451 }
452
453 fn from_slice(vulns: &[crate::diff::VulnerabilityDetail]) -> Vec<Self> {
454 vulns.iter().map(Self::from_detail).collect()
455 }
456}
457
458#[derive(Serialize)]
461struct JsonViewReport {
462 metadata: JsonViewMetadata,
463 summary: ViewSummary,
464 compliance: CraComplianceDetail,
465 components: Vec<ComponentView>,
466}
467
468#[derive(Serialize)]
469struct JsonViewMetadata {
470 tool: ToolInfo,
471 generated_at: String,
472 sbom: SbomInfo,
473}
474
475#[derive(Serialize)]
476struct ViewSummary {
477 total_components: usize,
478 total_dependencies: usize,
479 ecosystems: Vec<String>,
480 vulnerability_counts: crate::model::VulnerabilityCounts,
481}
482
483#[derive(Serialize)]
484struct ComponentView {
485 name: String,
486 version: Option<String>,
487 ecosystem: Option<String>,
488 licenses: Vec<String>,
489 supplier: Option<String>,
490 vulnerabilities: usize,
491 #[serde(skip_serializing_if = "Option::is_none")]
492 eol_status: Option<String>,
493 #[serde(skip_serializing_if = "Option::is_none")]
494 eol_date: Option<String>,
495 #[serde(skip_serializing_if = "Option::is_none")]
496 eol_product: Option<String>,
497}