Skip to main content

sbom_tools/reports/
mod.rs

1//! Report generation for diff results.
2//!
3//! This module provides multiple output formats for SBOM diff results:
4//! - JSON: Structured data for programmatic integration
5//! - SARIF: CI/CD security dashboard integration
6//! - Markdown: Human-readable documentation
7//! - HTML: Interactive stakeholder reports
8//! - Side-by-side: Terminal diff output like difftastic
9//! - Summary: Compact shell-friendly output
10//! - Table: Aligned tabular terminal output
11//!
12//! # Security
13//!
14//! The `escape` module provides utilities for safe output generation.
15//! All user-controllable data (component names, versions, etc.) should
16//! be escaped before embedding in HTML or Markdown reports.
17
18pub mod analyst;
19pub mod csaf;
20mod csv;
21pub mod escape;
22mod html;
23mod json;
24mod markdown;
25pub mod oscal;
26mod sarif;
27pub mod sbomqs_compat;
28mod sidebyside;
29pub mod streaming;
30mod summary;
31mod types;
32
33pub use csaf::{CsafEmitOptions, emit_csaf};
34pub use csv::CsvReporter;
35pub use html::HtmlReporter;
36pub use json::JsonReporter;
37pub use markdown::MarkdownReporter;
38pub use sarif::SarifReporter;
39pub use sarif::{
40    generate_ai_readiness_sarif, generate_compliance_sarif, generate_multi_compliance_sarif,
41    generate_quality_sarif,
42};
43pub use sidebyside::SideBySideReporter;
44pub use streaming::{
45    NdjsonReportGenerator, NdjsonReporter, NdjsonWriter, StreamingJsonReporter, StreamingJsonWriter,
46};
47pub use summary::{SummaryReporter, TableReporter};
48pub use types::{MinSeverity, ReportConfig, ReportFormat, ReportMetadata, ReportType};
49
50// Re-export traits
51// Note: StreamingReporter is implemented as a blanket impl for ReportGenerator
52
53use crate::diff::DiffResult;
54use crate::model::NormalizedSbom;
55use std::io::Write;
56use thiserror::Error;
57
58/// Errors that can occur during report generation
59#[derive(Error, Debug)]
60pub enum ReportError {
61    #[error("IO error: {0}")]
62    IoError(#[from] std::io::Error),
63
64    #[error("Serialization error: {0}")]
65    SerializationError(String),
66
67    #[error("Template error: {0}")]
68    TemplateError(String),
69
70    #[error("Invalid configuration: {0}")]
71    ConfigError(String),
72
73    #[error("Format error: {0}")]
74    FormatError(#[from] std::fmt::Error),
75}
76
77/// Trait for report generators
78pub trait ReportGenerator {
79    /// Generate a report from diff results
80    fn generate_diff_report(
81        &self,
82        result: &DiffResult,
83        old_sbom: &NormalizedSbom,
84        new_sbom: &NormalizedSbom,
85        config: &ReportConfig,
86    ) -> Result<String, ReportError>;
87
88    /// Generate a report for a single SBOM (view mode)
89    fn generate_view_report(
90        &self,
91        sbom: &NormalizedSbom,
92        config: &ReportConfig,
93    ) -> Result<String, ReportError>;
94
95    /// Write report to a writer
96    fn write_diff_report(
97        &self,
98        result: &DiffResult,
99        old_sbom: &NormalizedSbom,
100        new_sbom: &NormalizedSbom,
101        config: &ReportConfig,
102        writer: &mut dyn Write,
103    ) -> Result<(), ReportError> {
104        let report = self.generate_diff_report(result, old_sbom, new_sbom, config)?;
105        writer.write_all(report.as_bytes())?;
106        Ok(())
107    }
108
109    /// Get the format this generator produces
110    fn format(&self) -> ReportFormat;
111}
112
113/// Trait for writing reports directly to a [`Write`] sink.
114///
115/// Every `ReportGenerator` automatically implements this trait via a blanket
116/// impl that generates the full report string and writes it. Reporters that
117/// can write **incrementally** (e.g., [`StreamingJsonReporter`],
118/// [`NdjsonReporter`]) override this with truly streaming implementations
119/// that avoid buffering the entire output in memory.
120///
121/// # Example
122///
123/// ```ignore
124/// use sbom_tools::reports::{WriterReporter, JsonReporter, ReportConfig};
125/// use std::io::BufWriter;
126/// use std::fs::File;
127///
128/// let reporter = JsonReporter::new();
129/// let file = File::create("report.json")?;
130/// let mut writer = BufWriter::new(file);
131///
132/// reporter.write_diff_to(&result, &old, &new, &config, &mut writer)?;
133/// ```
134pub trait WriterReporter {
135    /// Write a diff report to a writer.
136    ///
137    /// Implementations may buffer the full report or write incrementally
138    /// depending on the reporter type.
139    fn write_diff_to<W: Write>(
140        &self,
141        result: &DiffResult,
142        old_sbom: &NormalizedSbom,
143        new_sbom: &NormalizedSbom,
144        config: &ReportConfig,
145        writer: &mut W,
146    ) -> Result<(), ReportError>;
147
148    /// Write a view report to a writer.
149    fn write_view_to<W: Write>(
150        &self,
151        sbom: &NormalizedSbom,
152        config: &ReportConfig,
153        writer: &mut W,
154    ) -> Result<(), ReportError>;
155
156    /// Get the format this reporter produces
157    fn format(&self) -> ReportFormat;
158}
159
160/// Backwards-compatible alias for `WriterReporter`.
161#[deprecated(since = "0.2.0", note = "Renamed to WriterReporter for clarity")]
162pub trait StreamingReporter: WriterReporter {}
163
164/// Blanket implementation of `WriterReporter` for any `ReportGenerator`.
165///
166/// Generates the full report in memory, then writes it. This is **not**
167/// streaming — it buffers the entire output. Reporters that need true
168/// incremental output (e.g., for very large SBOMs) should implement
169/// `WriterReporter` directly.
170impl<T: ReportGenerator> WriterReporter for T {
171    fn write_diff_to<W: Write>(
172        &self,
173        result: &DiffResult,
174        old_sbom: &NormalizedSbom,
175        new_sbom: &NormalizedSbom,
176        config: &ReportConfig,
177        writer: &mut W,
178    ) -> Result<(), ReportError> {
179        let report = self.generate_diff_report(result, old_sbom, new_sbom, config)?;
180        writer.write_all(report.as_bytes())?;
181        Ok(())
182    }
183
184    fn write_view_to<W: Write>(
185        &self,
186        sbom: &NormalizedSbom,
187        config: &ReportConfig,
188        writer: &mut W,
189    ) -> Result<(), ReportError> {
190        let report = self.generate_view_report(sbom, config)?;
191        writer.write_all(report.as_bytes())?;
192        Ok(())
193    }
194
195    fn format(&self) -> ReportFormat {
196        ReportGenerator::format(self)
197    }
198}
199
200#[allow(deprecated)]
201impl<T: WriterReporter> StreamingReporter for T {}
202
203/// Create a report generator for the given format
204#[must_use]
205pub fn create_reporter(format: ReportFormat) -> Box<dyn ReportGenerator> {
206    create_reporter_with_options(format, true)
207}
208
209/// Create a report generator with color control
210#[must_use]
211pub fn create_reporter_with_options(
212    format: ReportFormat,
213    use_color: bool,
214) -> Box<dyn ReportGenerator> {
215    match format {
216        ReportFormat::Auto | ReportFormat::Summary => {
217            if use_color {
218                Box::new(SummaryReporter::new())
219            } else {
220                Box::new(SummaryReporter::new().no_color())
221            }
222        }
223        ReportFormat::Json | ReportFormat::Tui => Box::new(JsonReporter::new()), // TUI uses JSON internally
224        ReportFormat::Sarif => Box::new(SarifReporter::new()),
225        ReportFormat::OscalJson => Box::new(JsonReporter::new()),
226        ReportFormat::Markdown => Box::new(MarkdownReporter::new()),
227        ReportFormat::Html => Box::new(HtmlReporter::new()),
228        ReportFormat::SideBySide => {
229            if use_color {
230                Box::new(SideBySideReporter::new())
231            } else {
232                Box::new(SideBySideReporter::new().no_colors())
233            }
234        }
235        ReportFormat::Table => {
236            if use_color {
237                Box::new(TableReporter::new())
238            } else {
239                Box::new(TableReporter::new().no_color())
240            }
241        }
242        ReportFormat::Csv => Box::new(CsvReporter::new()),
243        ReportFormat::Ndjson => Box::new(NdjsonReportGenerator::new()),
244        // sbomqs-json is a `quality`-command format (rendered by
245        // `reports::sbomqs_compat`, gated by QUALITY_OUTPUT_FORMATS); the
246        // diff/view pipeline has no sbomqs renderer, so it takes the same
247        // structured-JSON fallback as OscalJson.
248        ReportFormat::SbomqsJson => Box::new(JsonReporter::new()),
249    }
250}
251
252#[cfg(test)]
253mod factory_tests {
254    use super::{ReportConfig, ReportFormat, create_reporter_with_options};
255
256    /// The factory must honour `use_color` for EVERY colored format.
257    ///
258    /// The `SideBySide` arm used to ignore the flag and hand back a colored
259    /// reporter, so `--no-color`, `NO_COLOR=1`, and redirecting to a file all
260    /// still emitted ANSI escapes — the reporter's own `no_colors()` builder
261    /// was simply never called.
262    #[test]
263    fn colorable_formats_honour_use_color_false() {
264        let (diff, old, new) = crate::tui::test_support::demo_diff();
265        let config = ReportConfig::default();
266
267        for format in [
268            ReportFormat::SideBySide,
269            ReportFormat::Summary,
270            ReportFormat::Table,
271        ] {
272            let report = create_reporter_with_options(format, false)
273                .generate_diff_report(&diff, &old, &new, &config)
274                .expect("report generation must succeed");
275            assert!(
276                !report.contains('\u{1b}'),
277                "{format:?} emitted ANSI escapes with use_color=false"
278            );
279        }
280    }
281
282    /// The colored path still works — the fix must not disable colors outright.
283    #[test]
284    fn side_by_side_still_colors_when_enabled() {
285        let (diff, old, new) = crate::tui::test_support::demo_diff();
286        let report = create_reporter_with_options(ReportFormat::SideBySide, true)
287            .generate_diff_report(&diff, &old, &new, &ReportConfig::default())
288            .expect("report generation must succeed");
289        assert!(
290            report.contains('\u{1b}'),
291            "side-by-side must still color when use_color=true"
292        );
293    }
294}