use clap::ValueEnum;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum ReportFormat {
#[default]
#[serde(alias = "Auto")]
Auto,
#[serde(alias = "Tui")]
Tui,
#[value(alias = "side-by-side")]
#[serde(alias = "SideBySide")]
SideBySide,
#[serde(alias = "Json")]
Json,
#[serde(alias = "Sarif")]
Sarif,
#[serde(alias = "OscalJson")]
OscalJson,
#[serde(alias = "Markdown")]
Markdown,
#[serde(alias = "Html")]
Html,
#[serde(alias = "Summary")]
Summary,
#[serde(alias = "Table")]
Table,
#[serde(alias = "Csv")]
Csv,
#[serde(alias = "Ndjson")]
Ndjson,
#[serde(alias = "SbomqsJson")]
SbomqsJson,
}
impl std::fmt::Display for ReportFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Auto => write!(f, "auto"),
Self::Tui => write!(f, "tui"),
Self::SideBySide => write!(f, "side-by-side"),
Self::Json => write!(f, "json"),
Self::Sarif => write!(f, "sarif"),
Self::OscalJson => write!(f, "oscal-json"),
Self::Markdown => write!(f, "markdown"),
Self::Html => write!(f, "html"),
Self::Summary => write!(f, "summary"),
Self::Table => write!(f, "table"),
Self::Csv => write!(f, "csv"),
Self::Ndjson => write!(f, "ndjson"),
Self::SbomqsJson => write!(f, "sbomqs-json"),
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema,
)]
pub enum ReportType {
#[default]
All,
Components,
Dependencies,
Licenses,
Vulnerabilities,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum MinSeverity {
Low,
Medium,
High,
Critical,
}
impl MinSeverity {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"critical" => Some(Self::Critical),
_ => None,
}
}
#[must_use]
pub fn meets_threshold(&self, severity: &str) -> bool {
let sev = match severity.to_lowercase().as_str() {
"critical" => Self::Critical,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
_ => return true, };
sev >= *self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportConfig {
pub report_types: Vec<ReportType>,
pub max_items: Option<usize>,
pub include_field_changes: bool,
pub title: Option<String>,
pub metadata: ReportMetadata,
pub only_changes: bool,
pub min_severity: Option<MinSeverity>,
#[serde(skip)]
pub old_cra_compliance: Option<crate::quality::ComplianceResult>,
#[serde(skip)]
pub new_cra_compliance: Option<crate::quality::ComplianceResult>,
#[serde(skip)]
pub view_cra_compliance: Option<crate::quality::ComplianceResult>,
}
impl Default for ReportConfig {
fn default() -> Self {
Self {
report_types: vec![ReportType::All],
max_items: None,
include_field_changes: true,
title: None,
metadata: ReportMetadata::default(),
only_changes: false,
min_severity: None,
old_cra_compliance: None,
new_cra_compliance: None,
view_cra_compliance: None,
}
}
}
impl ReportConfig {
#[must_use]
pub fn all() -> Self {
Self::default()
}
#[must_use]
pub fn old_cra_compliance_or_bare(
&self,
old_sbom: &crate::model::NormalizedSbom,
) -> crate::quality::ComplianceResult {
self.old_cra_compliance
.clone()
.unwrap_or_else(|| bare_cra_phase2_check(old_sbom))
}
#[must_use]
pub fn new_cra_compliance_or_bare(
&self,
new_sbom: &crate::model::NormalizedSbom,
) -> crate::quality::ComplianceResult {
self.new_cra_compliance
.clone()
.unwrap_or_else(|| bare_cra_phase2_check(new_sbom))
}
#[must_use]
pub fn view_cra_compliance_or_bare(
&self,
sbom: &crate::model::NormalizedSbom,
) -> crate::quality::ComplianceResult {
self.view_cra_compliance
.clone()
.unwrap_or_else(|| bare_cra_phase2_check(sbom))
}
#[must_use]
pub fn with_types(types: Vec<ReportType>) -> Self {
Self {
report_types: types,
..Default::default()
}
}
#[must_use]
pub fn includes(&self, report_type: ReportType) -> bool {
self.report_types.contains(&ReportType::All) || self.report_types.contains(&report_type)
}
}
fn bare_cra_phase2_check(sbom: &crate::model::NormalizedSbom) -> crate::quality::ComplianceResult {
crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2).check(sbom)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReportMetadata {
pub old_sbom_path: Option<String>,
pub new_sbom_path: Option<String>,
pub tool_version: String,
pub generated_at: Option<String>,
pub custom: std::collections::HashMap<String, String>,
}
impl ReportMetadata {
#[must_use]
pub fn new() -> Self {
Self {
tool_version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_format_deserializes_kebab_case_and_legacy_pascal_case() {
for (raw, expected) in [
("auto", ReportFormat::Auto),
("tui", ReportFormat::Tui),
("side-by-side", ReportFormat::SideBySide),
("oscal-json", ReportFormat::OscalJson),
("Auto", ReportFormat::Auto),
("Json", ReportFormat::Json),
("SideBySide", ReportFormat::SideBySide),
("OscalJson", ReportFormat::OscalJson),
("Ndjson", ReportFormat::Ndjson),
("sbomqs-json", ReportFormat::SbomqsJson),
("SbomqsJson", ReportFormat::SbomqsJson),
] {
let parsed: ReportFormat = serde_json::from_str(&format!("\"{raw}\""))
.unwrap_or_else(|e| panic!("'{raw}' must deserialize: {e}"));
assert_eq!(parsed, expected, "'{raw}' mapped to the wrong variant");
}
}
#[test]
fn report_format_serializes_to_the_cli_spelling() {
for format in [
ReportFormat::Auto,
ReportFormat::Tui,
ReportFormat::SideBySide,
ReportFormat::Json,
ReportFormat::Sarif,
ReportFormat::OscalJson,
ReportFormat::Markdown,
ReportFormat::Html,
ReportFormat::Summary,
ReportFormat::Table,
ReportFormat::Csv,
ReportFormat::Ndjson,
ReportFormat::SbomqsJson,
] {
let serialized = serde_json::to_string(&format).unwrap();
assert_eq!(serialized, format!("\"{format}\""));
let round: ReportFormat = serde_json::from_str(&serialized).unwrap();
assert_eq!(round, format);
}
}
}