Skip to main content

sbom_tools/reports/
types.rs

1//! Report type definitions.
2
3use clap::ValueEnum;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Output format for reports
8///
9/// Serde names are kebab-case, matching the CLI spellings (`-o oscal-json`)
10/// so a hand-written config file can use the same values the CLI documents.
11/// The historical PascalCase variant names are kept as aliases so existing
12/// config files (`format: Json`) keep loading.
13#[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    /// Auto-detect: TUI if TTY, summary otherwise
20    #[default]
21    #[serde(alias = "Auto")]
22    Auto,
23    /// Interactive TUI display
24    #[serde(alias = "Tui")]
25    Tui,
26    /// Side-by-side terminal diff (like difftastic)
27    #[value(alias = "side-by-side")]
28    #[serde(alias = "SideBySide")]
29    SideBySide,
30    /// Structured JSON output
31    #[serde(alias = "Json")]
32    Json,
33    /// SARIF 2.1.0 for CI/CD
34    #[serde(alias = "Sarif")]
35    Sarif,
36    /// OSCAL 1.1.2 assessment-results JSON
37    #[serde(alias = "OscalJson")]
38    OscalJson,
39    /// Human-readable Markdown
40    #[serde(alias = "Markdown")]
41    Markdown,
42    /// Interactive HTML report
43    #[serde(alias = "Html")]
44    Html,
45    /// Brief summary output
46    #[serde(alias = "Summary")]
47    Summary,
48    /// Compact table for terminal (colored)
49    #[serde(alias = "Table")]
50    Table,
51    /// CSV for spreadsheet import
52    #[serde(alias = "Csv")]
53    Csv,
54    /// Newline-delimited JSON (one record per line, streaming-friendly)
55    #[serde(alias = "Ndjson")]
56    Ndjson,
57    /// interlynk-io/sbomqs `score --json`-shaped quality scores (0-10),
58    /// recomputed per-feature for side-by-side comparison with sbomqs
59    /// output (`quality` command only)
60    #[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/// Types of reports that can be generated
85#[derive(
86    Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema,
87)]
88pub enum ReportType {
89    /// All report types
90    #[default]
91    All,
92    /// Component changes summary
93    Components,
94    /// Dependency changes
95    Dependencies,
96    /// License changes
97    Licenses,
98    /// Vulnerability changes
99    Vulnerabilities,
100}
101
102/// Minimum severity level for filtering
103#[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    /// Parse severity from string. Returns None for unrecognized values.
113    #[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    /// Check if a severity string meets this minimum threshold
125    #[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, // Unknown severities are included
133        };
134        sev >= *self
135    }
136}
137
138/// Configuration for report generation
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ReportConfig {
141    /// Which report types to include
142    pub report_types: Vec<ReportType>,
143    /// Maximum items per section
144    pub max_items: Option<usize>,
145    /// Include detailed field changes
146    pub include_field_changes: bool,
147    /// Title for the report
148    pub title: Option<String>,
149    /// Additional metadata to include
150    pub metadata: ReportMetadata,
151    /// Only show items with changes (filter out unchanged)
152    pub only_changes: bool,
153    /// Minimum severity level for vulnerability filtering
154    pub min_severity: Option<MinSeverity>,
155    /// Pre-computed CRA compliance for old SBOM (avoids redundant recomputation)
156    #[serde(skip)]
157    pub old_cra_compliance: Option<crate::quality::ComplianceResult>,
158    /// Pre-computed CRA compliance for new SBOM (avoids redundant recomputation)
159    #[serde(skip)]
160    pub new_cra_compliance: Option<crate::quality::ComplianceResult>,
161    /// Pre-computed CRA compliance for single SBOM in view mode
162    #[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    /// Create a config for all report types
185    #[must_use]
186    pub fn all() -> Self {
187        Self::default()
188    }
189
190    /// CRA Phase 2 compliance for the old SBOM of a diff report.
191    ///
192    /// Returns the pre-computed [`Self::old_cra_compliance`] when populated —
193    /// every first-party pipeline (the diff report stage and the TUI export)
194    /// populates it with a sidecar-aware result so all output formats agree
195    /// with the TUI. The bare (sidecar-less) computation only exists as a
196    /// last resort for direct library callers that hand a reporter a default
197    /// `ReportConfig`; it lives here, in one place, so the individual
198    /// reporters cannot re-grow divergent fallback checkers.
199    #[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    /// CRA Phase 2 compliance for the new SBOM of a diff report.
210    ///
211    /// See [`Self::old_cra_compliance_or_bare`] for the fallback contract.
212    #[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    /// CRA Phase 2 compliance for the SBOM of a view report.
223    ///
224    /// See [`Self::old_cra_compliance_or_bare`] for the fallback contract.
225    #[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    /// Create a config for specific report types
236    #[must_use]
237    pub fn with_types(types: Vec<ReportType>) -> Self {
238        Self {
239            report_types: types,
240            ..Default::default()
241        }
242    }
243
244    /// Check if a report type should be included
245    #[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
251/// Last-resort CRA Phase 2 check with no sidecar or product class attached.
252///
253/// Only reachable through the `*_or_bare` accessors on [`ReportConfig`] when a
254/// caller did not pre-compute compliance. First-party pipelines never hit this:
255/// they resolve the CRA sidecar (explicit flag or `<sbom>.cra.{json,yaml}`
256/// auto-discovery) and populate the config fields so every output format
257/// renders the same verdicts as the TUI.
258fn 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/// Metadata included in reports
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct ReportMetadata {
265    /// Old SBOM file path
266    pub old_sbom_path: Option<String>,
267    /// New SBOM file path
268    pub new_sbom_path: Option<String>,
269    /// Tool version
270    pub tool_version: String,
271    /// Generation timestamp
272    pub generated_at: Option<String>,
273    /// Custom properties
274    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        // kebab-case is the canonical (CLI-matching) spelling; the historical
294        // PascalCase names must keep loading so existing configs don't break.
295        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        // Serialization, Display (CLI), and deserialization agree, so a
317        // `config show`/`config check` round-trip is loss-free.
318        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}