Skip to main content

sbom_diff/renderer/
mod.rs

1//! output renderers for displaying SBOM diffs.
2//!
3//! this module provides formatters for different output contexts:
4//!
5//! - [`TextRenderer`] - Plain text for terminal output
6//! - [`MarkdownRenderer`] - GitHub-flavored markdown for PR comments
7//! - [`JsonRenderer`] - Machine-readable JSON for tooling integration
8//! - [`SarifRenderer`] - SARIF 2.1.0 for GitHub Code Scanning / Azure DevOps
9//! - [`CsvRenderer`] - RFC 4180 CSV for spreadsheets, CI dashboards, and data pipelines
10
11mod csv_format;
12mod json;
13mod markdown;
14mod sarif;
15mod text;
16
17pub use csv_format::CsvRenderer;
18pub use json::JsonRenderer;
19pub use markdown::MarkdownRenderer;
20pub use sarif::SarifRenderer;
21pub use text::TextRenderer;
22
23use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange};
24use sbom_model::DependencyKind;
25use std::collections::{BTreeMap, BTreeSet};
26use std::io::Write;
27
28/// options controlling how diffs are rendered.
29#[derive(Debug, Clone, Default)]
30pub struct RenderOptions {
31    /// when true, include a per-ecosystem breakdown of added/removed/changed counts.
32    pub group_by_ecosystem: bool,
33    /// when true, include parser warnings in the output.
34    pub show_warnings: bool,
35    /// parser warnings from the old SBOM.
36    pub old_warnings: Vec<String>,
37    /// parser warnings from the new SBOM.
38    pub new_warnings: Vec<String>,
39}
40
41impl RenderOptions {
42    /// returns true when warnings should be displayed.
43    pub fn has_warnings(&self) -> bool {
44        self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
45    }
46
47    /// total number of warnings across both SBOMs.
48    pub fn warning_count(&self) -> usize {
49        self.old_warnings.len() + self.new_warnings.len()
50    }
51}
52
53/// returns a display suffix for a dependency kind.
54/// runtime dependencies get no suffix (they are the default/common case).
55pub(super) fn kind_suffix(kind: &DependencyKind) -> &'static str {
56    match kind {
57        DependencyKind::Runtime => "",
58        DependencyKind::Dev => " (dev)",
59        DependencyKind::Build => " (build)",
60        DependencyKind::Test => " (test)",
61        DependencyKind::Optional => " (optional)",
62        DependencyKind::Provided => " (provided)",
63    }
64}
65
66/// formats an `Option<String>` for display, returning `"<none>"` for `None`.
67pub fn format_option(opt: &Option<String>) -> &str {
68    opt.as_deref().unwrap_or("<none>")
69}
70
71/// formats a `BTreeSet<String>` as a comma-separated string, or `"<none>"` if empty.
72pub fn format_set(set: &BTreeSet<String>) -> String {
73    if set.is_empty() {
74        "<none>".to_string()
75    } else {
76        let mut out = String::new();
77        for (i, s) in set.iter().enumerate() {
78            if i > 0 {
79                out.push_str(", ");
80            }
81            out.push_str(s);
82        }
83        out
84    }
85}
86
87/// trait for rendering a [`Diff`] to an output stream.
88pub trait Renderer {
89    /// writes the formatted diff to the provided writer.
90    fn render<W: Write>(
91        &self,
92        diff: &Diff,
93        opts: &RenderOptions,
94        writer: &mut W,
95    ) -> anyhow::Result<()>;
96}
97
98/// trait for rendering a summary (counts only, no component details) to an output stream.
99///
100/// mirrors [`Renderer`] but produces compact output suitable for `--summary` mode.
101pub trait SummaryRenderer {
102    /// writes a summary-only view of the diff to the provided writer.
103    fn render_summary<W: Write>(
104        &self,
105        diff: &Diff,
106        opts: &RenderOptions,
107        writer: &mut W,
108    ) -> anyhow::Result<()>;
109}
110
111// --- Shared helpers for field-change rendering ---
112
113pub(super) trait FieldChangeFormatter {
114    fn field_change<W: Write>(
115        &self,
116        w: &mut W,
117        name: &str,
118        old: &str,
119        new: &str,
120    ) -> std::io::Result<()>;
121    fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
122    fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
123    fn hash_changed<W: Write>(
124        &self,
125        w: &mut W,
126        algo: &str,
127        old: &str,
128        new: &str,
129    ) -> std::io::Result<()>;
130    fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
131    fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
132}
133
134pub(super) fn write_field_changes<F: FieldChangeFormatter, W: Write>(
135    fmt: &F,
136    writer: &mut W,
137    changes: &[FieldChange],
138) -> std::io::Result<()> {
139    for change in changes {
140        match change {
141            FieldChange::Version(old, new) => {
142                fmt.field_change(writer, "Version", format_option(old), format_option(new))?;
143            }
144            FieldChange::License(old, new) => {
145                fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
146            }
147            FieldChange::Supplier(old, new) => {
148                fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
149            }
150            FieldChange::Purl(old, new) => {
151                fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
152            }
153            FieldChange::Description(old, new) => {
154                fmt.field_change(
155                    writer,
156                    "Description",
157                    format_option(old),
158                    format_option(new),
159                )?;
160            }
161            FieldChange::Hashes(old, new) => {
162                fmt.hash_header(writer)?;
163                for (algo, digest) in old {
164                    if !new.contains_key(algo) {
165                        fmt.hash_removed(writer, algo, digest)?;
166                    } else if new[algo] != *digest {
167                        fmt.hash_changed(writer, algo, digest, &new[algo])?;
168                    }
169                }
170                for (algo, digest) in new {
171                    if !old.contains_key(algo) {
172                        fmt.hash_added(writer, algo, digest)?;
173                    }
174                }
175            }
176            FieldChange::Ecosystem(old, new) => {
177                fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
178            }
179        }
180    }
181    Ok(())
182}
183
184pub(super) fn write_changed<F: FieldChangeFormatter, W: Write>(
185    fmt: &F,
186    writer: &mut W,
187    changes: &[ComponentChange],
188) -> std::io::Result<()> {
189    for c in changes {
190        fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
191        write_field_changes(fmt, writer, &c.changes)?;
192    }
193    Ok(())
194}
195
196/// format-specific building blocks for summary output.
197///
198/// text and markdown renderers implement this trait; the shared
199/// [`write_summary`] function orchestrates calls in the correct order.
200/// JSON uses a fundamentally different approach (building a single
201/// serializable value) and implements [`SummaryRenderer`] directly.
202pub(super) trait SummaryFormatter {
203    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
204    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
205    fn write_ecosystem_breakdown<W: Write>(
206        &self,
207        w: &mut W,
208        breakdown: &BTreeMap<String, EcosystemCounts>,
209    ) -> std::io::Result<()>;
210}
211
212pub(super) fn write_summary<F: SummaryFormatter, W: Write>(
213    fmt: &F,
214    diff: &Diff,
215    opts: &RenderOptions,
216    writer: &mut W,
217) -> std::io::Result<()> {
218    if opts.has_warnings() {
219        fmt.write_warnings(writer, opts)?;
220    }
221    fmt.write_counts(writer, diff)?;
222    if opts.group_by_ecosystem {
223        let breakdown = diff.ecosystem_breakdown();
224        if !breakdown.is_empty() {
225            fmt.write_ecosystem_breakdown(writer, &breakdown)?;
226        }
227    }
228    Ok(())
229}
230
231pub(super) fn format_vec_or_none(v: &[String]) -> String {
232    if v.is_empty() {
233        "<none>".to_string()
234    } else {
235        v.join(", ")
236    }
237}
238
239#[cfg(test)]
240mod tests;