use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
use crate::diff::DiffResult;
use crate::model::NormalizedSbom;
use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
use chrono::Utc;
use serde::Serialize;
pub struct JsonReporter {
summary_only: bool,
pretty: bool,
}
impl JsonReporter {
#[must_use]
pub const fn new() -> Self {
Self {
summary_only: false,
pretty: true,
}
}
#[must_use]
pub const fn summary_only() -> Self {
Self {
summary_only: true,
pretty: true,
}
}
#[must_use]
pub const fn pretty(mut self, pretty: bool) -> Self {
self.pretty = pretty;
self
}
}
impl Default for JsonReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for JsonReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let old_cra = config
.old_cra_compliance
.clone()
.unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom));
let new_cra = config
.new_cra_compliance
.clone()
.unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom));
let cra_compliance = CraCompliance {
old: CraComplianceDetail::from_result(old_cra),
new: CraComplianceDetail::from_result(new_cra),
};
let report = JsonDiffReport {
metadata: JsonReportMetadata {
tool: ToolInfo {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
generated_at: Utc::now().to_rfc3339(),
old_sbom: SbomInfo {
format: old_sbom.document.format.to_string(),
file_path: config.metadata.old_sbom_path.clone(),
component_count: old_sbom.component_count(),
},
new_sbom: SbomInfo {
format: new_sbom.document.format.to_string(),
file_path: config.metadata.new_sbom_path.clone(),
component_count: new_sbom.component_count(),
},
},
summary: JsonSummary {
total_changes: result.summary.total_changes,
components: ComponentSummary {
added: result.summary.components_added,
removed: result.summary.components_removed,
modified: result.summary.components_modified,
},
vulnerabilities: VulnerabilitySummary {
introduced: result.summary.vulnerabilities_introduced,
resolved: result.summary.vulnerabilities_resolved,
persistent: result.summary.vulnerabilities_persistent,
},
semantic_score: result.semantic_score,
},
cra_compliance,
reports: if self.summary_only {
None
} else {
Some(JsonReports {
components: if config.includes(ReportType::Components) {
Some(ComponentsReport {
added: &result.components.added,
removed: &result.components.removed,
modified: &result.components.modified,
})
} else {
None
},
dependencies: if config.includes(ReportType::Dependencies) {
Some(DependenciesReport {
added: &result.dependencies.added,
removed: &result.dependencies.removed,
})
} else {
None
},
licenses: if config.includes(ReportType::Licenses) {
Some(LicensesReport {
new_licenses: &result.licenses.new_licenses,
removed_licenses: &result.licenses.removed_licenses,
conflicts: &result.licenses.conflicts,
})
} else {
None
},
vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
Some(VulnerabilitiesReport {
introduced: VulnerabilityWithSla::from_slice(
&result.vulnerabilities.introduced,
),
resolved: VulnerabilityWithSla::from_slice(
&result.vulnerabilities.resolved,
),
persistent: VulnerabilityWithSla::from_slice(
&result.vulnerabilities.persistent,
),
})
} else {
None
},
})
},
};
let json = if self.pretty {
serde_json::to_string_pretty(&report)
} else {
serde_json::to_string(&report)
}
.map_err(|e| ReportError::SerializationError(e.to_string()))?;
Ok(json)
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let cra_result = config
.view_cra_compliance
.clone()
.unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
let compliance = CraComplianceDetail::from_result(cra_result);
let report = JsonViewReport {
metadata: JsonViewMetadata {
tool: ToolInfo {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
generated_at: Utc::now().to_rfc3339(),
sbom: SbomInfo {
format: sbom.document.format.to_string(),
file_path: config.metadata.old_sbom_path.clone(),
component_count: sbom.component_count(),
},
},
summary: ViewSummary {
total_components: sbom.component_count(),
total_dependencies: sbom.edges.len(),
ecosystems: sbom
.ecosystems()
.iter()
.map(std::string::ToString::to_string)
.collect(),
vulnerability_counts: sbom.vulnerability_counts(),
},
compliance,
components: sbom
.components
.values()
.map(|c| ComponentView {
name: c.name.clone(),
version: c.version.clone(),
ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
licenses: c
.licenses
.declared
.iter()
.map(|l| l.expression.clone())
.collect(),
supplier: c.supplier.as_ref().map(|s| s.name.clone()),
vulnerabilities: c.vulnerabilities.len(),
eol_status: c.eol.as_ref().map(|e| e.status.label().to_string()),
eol_date: c
.eol
.as_ref()
.and_then(|e| e.eol_date.map(|d| d.to_string())),
eol_product: c.eol.as_ref().map(|e| e.product.clone()),
})
.collect(),
};
let json = if self.pretty {
serde_json::to_string_pretty(&report)
} else {
serde_json::to_string(&report)
}
.map_err(|e| ReportError::SerializationError(e.to_string()))?;
Ok(json)
}
fn format(&self) -> ReportFormat {
ReportFormat::Json
}
}
#[derive(Serialize)]
struct JsonDiffReport<'a> {
metadata: JsonReportMetadata,
summary: JsonSummary,
cra_compliance: CraCompliance,
#[serde(skip_serializing_if = "Option::is_none")]
reports: Option<JsonReports<'a>>,
}
#[derive(Serialize)]
struct CraCompliance {
old: CraComplianceDetail,
new: CraComplianceDetail,
}
#[derive(Serialize)]
struct CraComplianceDetail {
#[serde(flatten)]
result: ComplianceResult,
article_summary: CraArticleSummary,
}
#[derive(Serialize)]
struct CraArticleSummary {
#[serde(rename = "art_13_4_machine_readable_format")]
art_13_4: usize,
#[serde(rename = "art_13_6_vulnerability_disclosure")]
art_13_6: usize,
#[serde(rename = "art_13_7_coordinated_disclosure")]
art_13_7: usize,
#[serde(rename = "art_13_8_support_period")]
art_13_8: usize,
#[serde(rename = "art_13_11_component_lifecycle")]
art_13_11: usize,
#[serde(rename = "art_13_12_product_identification")]
art_13_12: usize,
#[serde(rename = "art_13_15_manufacturer_identification")]
art_13_15: usize,
#[serde(rename = "annex_i_technical_documentation")]
annex_i: usize,
#[serde(rename = "annex_vii_declaration_of_conformity")]
annex_vii: usize,
}
impl CraComplianceDetail {
fn from_result(result: ComplianceResult) -> Self {
let mut summary = CraArticleSummary {
art_13_4: 0,
art_13_6: 0,
art_13_7: 0,
art_13_8: 0,
art_13_11: 0,
art_13_12: 0,
art_13_15: 0,
annex_i: 0,
annex_vii: 0,
};
for violation in &result.violations {
let req = violation.requirement.to_lowercase();
if req.contains("art. 13(4)") || req.contains("art.13(4)") {
summary.art_13_4 += 1;
} else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
summary.art_13_6 += 1;
} else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
summary.art_13_7 += 1;
} else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
summary.art_13_8 += 1;
} else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
summary.art_13_11 += 1;
} else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
summary.art_13_12 += 1;
} else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
summary.art_13_15 += 1;
} else if req.contains("annex vii") {
summary.annex_vii += 1;
} else if req.contains("annex i") || req.contains("annex_i") {
summary.annex_i += 1;
}
}
Self {
result,
article_summary: summary,
}
}
}
#[derive(Serialize)]
struct JsonReportMetadata {
tool: ToolInfo,
generated_at: String,
old_sbom: SbomInfo,
new_sbom: SbomInfo,
}
#[derive(Serialize)]
struct ToolInfo {
name: String,
version: String,
}
#[derive(Serialize)]
struct SbomInfo {
format: String,
#[serde(skip_serializing_if = "Option::is_none")]
file_path: Option<String>,
component_count: usize,
}
#[derive(Serialize)]
struct JsonSummary {
total_changes: usize,
components: ComponentSummary,
vulnerabilities: VulnerabilitySummary,
semantic_score: f64,
}
#[derive(Serialize)]
struct ComponentSummary {
added: usize,
removed: usize,
modified: usize,
}
#[derive(Serialize)]
struct VulnerabilitySummary {
introduced: usize,
resolved: usize,
persistent: usize,
}
#[derive(Serialize)]
struct JsonReports<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
components: Option<ComponentsReport<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
dependencies: Option<DependenciesReport<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
licenses: Option<LicensesReport<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
vulnerabilities: Option<VulnerabilitiesReport>,
}
#[derive(Serialize)]
struct ComponentsReport<'a> {
added: &'a [crate::diff::ComponentChange],
removed: &'a [crate::diff::ComponentChange],
modified: &'a [crate::diff::ComponentChange],
}
#[derive(Serialize)]
struct DependenciesReport<'a> {
added: &'a [crate::diff::DependencyChange],
removed: &'a [crate::diff::DependencyChange],
}
#[derive(Serialize)]
struct LicensesReport<'a> {
new_licenses: &'a [crate::diff::LicenseChange],
removed_licenses: &'a [crate::diff::LicenseChange],
conflicts: &'a [crate::diff::LicenseConflict],
}
#[derive(Serialize)]
struct VulnerabilitiesReport {
introduced: Vec<VulnerabilityWithSla>,
resolved: Vec<VulnerabilityWithSla>,
persistent: Vec<VulnerabilityWithSla>,
}
#[derive(Serialize)]
struct VulnerabilityWithSla {
#[serde(flatten)]
detail: crate::diff::VulnerabilityDetail,
sla_status: String,
sla_category: String,
}
impl VulnerabilityWithSla {
fn from_detail(v: &crate::diff::VulnerabilityDetail) -> Self {
let sla = v.sla_status();
let (status_text, category) = match &sla {
crate::diff::SlaStatus::Overdue(days) => (format!("{days}d overdue"), "overdue"),
crate::diff::SlaStatus::DueSoon(days) => (format!("{days}d remaining"), "due_soon"),
crate::diff::SlaStatus::OnTrack(days) => (format!("{days}d remaining"), "on_track"),
crate::diff::SlaStatus::NoDueDate => {
let text = v
.days_since_published
.map_or_else(|| "unknown".to_string(), |d| format!("{d}d old"));
(text, "no_due_date")
}
};
Self {
detail: v.clone(),
sla_status: status_text,
sla_category: category.to_string(),
}
}
fn from_slice(vulns: &[crate::diff::VulnerabilityDetail]) -> Vec<Self> {
vulns.iter().map(Self::from_detail).collect()
}
}
#[derive(Serialize)]
struct JsonViewReport {
metadata: JsonViewMetadata,
summary: ViewSummary,
compliance: CraComplianceDetail,
components: Vec<ComponentView>,
}
#[derive(Serialize)]
struct JsonViewMetadata {
tool: ToolInfo,
generated_at: String,
sbom: SbomInfo,
}
#[derive(Serialize)]
struct ViewSummary {
total_components: usize,
total_dependencies: usize,
ecosystems: Vec<String>,
vulnerability_counts: crate::model::VulnerabilityCounts,
}
#[derive(Serialize)]
struct ComponentView {
name: String,
version: Option<String>,
ecosystem: Option<String>,
licenses: Vec<String>,
supplier: Option<String>,
vulnerabilities: usize,
#[serde(skip_serializing_if = "Option::is_none")]
eol_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
eol_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
eol_product: Option<String>,
}