1use clap::ValueEnum;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(
14 Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema,
15)]
16#[serde(rename_all = "kebab-case")]
17#[non_exhaustive]
18pub enum ReportFormat {
19 #[default]
21 #[serde(alias = "Auto")]
22 Auto,
23 #[serde(alias = "Tui")]
25 Tui,
26 #[value(alias = "side-by-side")]
28 #[serde(alias = "SideBySide")]
29 SideBySide,
30 #[serde(alias = "Json")]
32 Json,
33 #[serde(alias = "Sarif")]
35 Sarif,
36 #[serde(alias = "OscalJson")]
38 OscalJson,
39 #[serde(alias = "Markdown")]
41 Markdown,
42 #[serde(alias = "Html")]
44 Html,
45 #[serde(alias = "Summary")]
47 Summary,
48 #[serde(alias = "Table")]
50 Table,
51 #[serde(alias = "Csv")]
53 Csv,
54 #[serde(alias = "Ndjson")]
56 Ndjson,
57 #[serde(alias = "SbomqsJson")]
61 SbomqsJson,
62}
63
64impl std::fmt::Display for ReportFormat {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::Auto => write!(f, "auto"),
68 Self::Tui => write!(f, "tui"),
69 Self::SideBySide => write!(f, "side-by-side"),
70 Self::Json => write!(f, "json"),
71 Self::Sarif => write!(f, "sarif"),
72 Self::OscalJson => write!(f, "oscal-json"),
73 Self::Markdown => write!(f, "markdown"),
74 Self::Html => write!(f, "html"),
75 Self::Summary => write!(f, "summary"),
76 Self::Table => write!(f, "table"),
77 Self::Csv => write!(f, "csv"),
78 Self::Ndjson => write!(f, "ndjson"),
79 Self::SbomqsJson => write!(f, "sbomqs-json"),
80 }
81 }
82}
83
84#[derive(
86 Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema,
87)]
88pub enum ReportType {
89 #[default]
91 All,
92 Components,
94 Dependencies,
96 Licenses,
98 Vulnerabilities,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
104pub enum MinSeverity {
105 Low,
106 Medium,
107 High,
108 Critical,
109}
110
111impl MinSeverity {
112 #[must_use]
114 pub fn parse(s: &str) -> Option<Self> {
115 match s.to_lowercase().as_str() {
116 "low" => Some(Self::Low),
117 "medium" => Some(Self::Medium),
118 "high" => Some(Self::High),
119 "critical" => Some(Self::Critical),
120 _ => None,
121 }
122 }
123
124 #[must_use]
126 pub fn meets_threshold(&self, severity: &str) -> bool {
127 let sev = match severity.to_lowercase().as_str() {
128 "critical" => Self::Critical,
129 "high" => Self::High,
130 "medium" => Self::Medium,
131 "low" => Self::Low,
132 _ => return true, };
134 sev >= *self
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ReportConfig {
141 pub report_types: Vec<ReportType>,
143 pub max_items: Option<usize>,
145 pub include_field_changes: bool,
147 pub title: Option<String>,
149 pub metadata: ReportMetadata,
151 pub only_changes: bool,
153 pub min_severity: Option<MinSeverity>,
155 #[serde(skip)]
157 pub old_cra_compliance: Option<crate::quality::ComplianceResult>,
158 #[serde(skip)]
160 pub new_cra_compliance: Option<crate::quality::ComplianceResult>,
161 #[serde(skip)]
163 pub view_cra_compliance: Option<crate::quality::ComplianceResult>,
164}
165
166impl Default for ReportConfig {
167 fn default() -> Self {
168 Self {
169 report_types: vec![ReportType::All],
170 max_items: None,
171 include_field_changes: true,
172 title: None,
173 metadata: ReportMetadata::default(),
174 only_changes: false,
175 min_severity: None,
176 old_cra_compliance: None,
177 new_cra_compliance: None,
178 view_cra_compliance: None,
179 }
180 }
181}
182
183impl ReportConfig {
184 #[must_use]
186 pub fn all() -> Self {
187 Self::default()
188 }
189
190 #[must_use]
200 pub fn old_cra_compliance_or_bare(
201 &self,
202 old_sbom: &crate::model::NormalizedSbom,
203 ) -> crate::quality::ComplianceResult {
204 self.old_cra_compliance
205 .clone()
206 .unwrap_or_else(|| bare_cra_phase2_check(old_sbom))
207 }
208
209 #[must_use]
213 pub fn new_cra_compliance_or_bare(
214 &self,
215 new_sbom: &crate::model::NormalizedSbom,
216 ) -> crate::quality::ComplianceResult {
217 self.new_cra_compliance
218 .clone()
219 .unwrap_or_else(|| bare_cra_phase2_check(new_sbom))
220 }
221
222 #[must_use]
226 pub fn view_cra_compliance_or_bare(
227 &self,
228 sbom: &crate::model::NormalizedSbom,
229 ) -> crate::quality::ComplianceResult {
230 self.view_cra_compliance
231 .clone()
232 .unwrap_or_else(|| bare_cra_phase2_check(sbom))
233 }
234
235 #[must_use]
237 pub fn with_types(types: Vec<ReportType>) -> Self {
238 Self {
239 report_types: types,
240 ..Default::default()
241 }
242 }
243
244 #[must_use]
246 pub fn includes(&self, report_type: ReportType) -> bool {
247 self.report_types.contains(&ReportType::All) || self.report_types.contains(&report_type)
248 }
249}
250
251fn bare_cra_phase2_check(sbom: &crate::model::NormalizedSbom) -> crate::quality::ComplianceResult {
259 crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2).check(sbom)
260}
261
262#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct ReportMetadata {
265 pub old_sbom_path: Option<String>,
267 pub new_sbom_path: Option<String>,
269 pub tool_version: String,
271 pub generated_at: Option<String>,
273 pub custom: std::collections::HashMap<String, String>,
275}
276
277impl ReportMetadata {
278 #[must_use]
279 pub fn new() -> Self {
280 Self {
281 tool_version: env!("CARGO_PKG_VERSION").to_string(),
282 ..Default::default()
283 }
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn report_format_deserializes_kebab_case_and_legacy_pascal_case() {
293 for (raw, expected) in [
296 ("auto", ReportFormat::Auto),
297 ("tui", ReportFormat::Tui),
298 ("side-by-side", ReportFormat::SideBySide),
299 ("oscal-json", ReportFormat::OscalJson),
300 ("Auto", ReportFormat::Auto),
301 ("Json", ReportFormat::Json),
302 ("SideBySide", ReportFormat::SideBySide),
303 ("OscalJson", ReportFormat::OscalJson),
304 ("Ndjson", ReportFormat::Ndjson),
305 ("sbomqs-json", ReportFormat::SbomqsJson),
306 ("SbomqsJson", ReportFormat::SbomqsJson),
307 ] {
308 let parsed: ReportFormat = serde_json::from_str(&format!("\"{raw}\""))
309 .unwrap_or_else(|e| panic!("'{raw}' must deserialize: {e}"));
310 assert_eq!(parsed, expected, "'{raw}' mapped to the wrong variant");
311 }
312 }
313
314 #[test]
315 fn report_format_serializes_to_the_cli_spelling() {
316 for format in [
319 ReportFormat::Auto,
320 ReportFormat::Tui,
321 ReportFormat::SideBySide,
322 ReportFormat::Json,
323 ReportFormat::Sarif,
324 ReportFormat::OscalJson,
325 ReportFormat::Markdown,
326 ReportFormat::Html,
327 ReportFormat::Summary,
328 ReportFormat::Table,
329 ReportFormat::Csv,
330 ReportFormat::Ndjson,
331 ReportFormat::SbomqsJson,
332 ] {
333 let serialized = serde_json::to_string(&format).unwrap();
334 assert_eq!(serialized, format!("\"{format}\""));
335 let round: ReportFormat = serde_json::from_str(&serialized).unwrap();
336 assert_eq!(round, format);
337 }
338 }
339}