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, EdgeDiff, FieldChange};
24use sbom_model::{is_hash_algorithm_downgrade, Component, 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
111pub(super) trait FieldChangeFormatter {
112    fn field_change<W: Write>(
113        &self,
114        w: &mut W,
115        name: &str,
116        old: &str,
117        new: &str,
118    ) -> std::io::Result<()>;
119    fn hash_header<W: Write>(&self, w: &mut W, downgrade: bool) -> std::io::Result<()>;
120    fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
121    fn hash_changed<W: Write>(
122        &self,
123        w: &mut W,
124        algo: &str,
125        old: &str,
126        new: &str,
127    ) -> std::io::Result<()>;
128    fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
129    fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
130}
131
132pub(super) fn write_field_changes<F: FieldChangeFormatter, W: Write>(
133    fmt: &F,
134    writer: &mut W,
135    changes: &[FieldChange],
136    is_downgrade: bool,
137) -> std::io::Result<()> {
138    for change in changes {
139        match change {
140            FieldChange::Version(old, new) => {
141                let label = if is_downgrade {
142                    "Version (downgrade)"
143                } else {
144                    "Version"
145                };
146                fmt.field_change(writer, label, format_option(old), format_option(new))?;
147            }
148            FieldChange::License(old, new) => {
149                fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
150            }
151            FieldChange::LicenseExpression(old, new) => {
152                fmt.field_change(
153                    writer,
154                    "License expression",
155                    format_option(old),
156                    format_option(new),
157                )?;
158            }
159            FieldChange::Supplier(old, new) => {
160                fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
161            }
162            FieldChange::Purl(old, new) => {
163                fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
164            }
165            FieldChange::Description(old, new) => {
166                fmt.field_change(
167                    writer,
168                    "Description",
169                    format_option(old),
170                    format_option(new),
171                )?;
172            }
173            FieldChange::Hashes(old, new) => {
174                fmt.hash_header(writer, is_hash_algorithm_downgrade(old, new))?;
175                for (algo, digest) in old {
176                    if !new.contains_key(algo) {
177                        fmt.hash_removed(writer, algo, digest)?;
178                    } else if new[algo] != *digest {
179                        fmt.hash_changed(writer, algo, digest, &new[algo])?;
180                    }
181                }
182                for (algo, digest) in new {
183                    if !old.contains_key(algo) {
184                        fmt.hash_added(writer, algo, digest)?;
185                    }
186                }
187            }
188            FieldChange::Ecosystem(old, new) => {
189                fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
190            }
191        }
192    }
193    Ok(())
194}
195
196pub(super) fn write_changed<F: FieldChangeFormatter, W: Write>(
197    fmt: &F,
198    writer: &mut W,
199    changes: &[ComponentChange],
200) -> std::io::Result<()> {
201    for c in changes {
202        fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
203        write_field_changes(fmt, writer, &c.changes, c.is_downgrade)?;
204    }
205    Ok(())
206}
207
208/// format-specific building blocks for summary output.
209///
210/// text and markdown renderers implement this trait; the shared
211/// [`write_summary`] function orchestrates calls in the correct order.
212/// JSON uses a fundamentally different approach (building a single
213/// serializable value) and implements [`SummaryRenderer`] directly.
214pub(super) trait SummaryFormatter {
215    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
216    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
217    fn write_ecosystem_breakdown<W: Write>(
218        &self,
219        w: &mut W,
220        breakdown: &BTreeMap<String, EcosystemCounts>,
221    ) -> std::io::Result<()>;
222}
223
224pub(super) fn write_summary<F: SummaryFormatter, W: Write>(
225    fmt: &F,
226    diff: &Diff,
227    opts: &RenderOptions,
228    writer: &mut W,
229) -> std::io::Result<()> {
230    if opts.has_warnings() {
231        fmt.write_warnings(writer, opts)?;
232    }
233    fmt.write_counts(writer, diff)?;
234    if opts.group_by_ecosystem {
235        let breakdown = diff.ecosystem_breakdown();
236        if !breakdown.is_empty() {
237            fmt.write_ecosystem_breakdown(writer, &breakdown)?;
238        }
239    }
240    Ok(())
241}
242
243/// which component section is being rendered.
244///
245/// used by [`FullFormatter::section_open`] to pick the correct heading.
246#[derive(Clone, Copy)]
247pub(super) enum SectionKind {
248    Added,
249    Removed,
250    Changed,
251}
252
253/// format-specific building blocks for the full (non-summary) diff output.
254///
255/// text and markdown renderers implement this trait; the shared
256/// [`write_full`] function walks the diff and calls these hooks in the
257/// correct order, so both formats share one section skeleton. each hook
258/// owns the exact bytes (including blank lines) for its piece of output.
259/// JSON/SARIF/CSV build serializable values or write records and are
260/// structurally different, so they implement [`Renderer`] directly.
261pub(super) trait FullFormatter: FieldChangeFormatter {
262    /// warnings block, only called when [`RenderOptions::has_warnings`].
263    fn full_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
264    /// summary-count header plus its trailing blank line.
265    fn full_count_header<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
266    /// per-ecosystem count table (only in `group_by_ecosystem` mode).
267    fn full_ecosystem_breakdown<W: Write>(
268        &self,
269        w: &mut W,
270        breakdown: &BTreeMap<String, EcosystemCounts>,
271    ) -> std::io::Result<()>;
272    /// heading introducing one ecosystem's sections.
273    fn full_ecosystem_header<W: Write>(&self, w: &mut W, ecosystem: &str) -> std::io::Result<()>;
274    /// opens an added/removed/changed section (heading only).
275    fn section_open<W: Write>(
276        &self,
277        w: &mut W,
278        kind: SectionKind,
279        count: usize,
280    ) -> std::io::Result<()>;
281    /// closes an added/removed/changed section, emitting the trailing blank line.
282    fn section_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
283    /// renders the component list body of an added or removed section.
284    fn component_list<W: Write>(&self, w: &mut W, components: &[Component]) -> std::io::Result<()>;
285    /// opens the edge-changes section.
286    fn edge_open<W: Write>(&self, w: &mut W, count: usize) -> std::io::Result<()>;
287    /// renders one parent's edge changes.
288    fn edge_entry<W: Write>(&self, w: &mut W, diff: &Diff, edge: &EdgeDiff) -> std::io::Result<()>;
289    /// closes the edge-changes section.
290    fn edge_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
291    /// opens the metadata-changes section.
292    fn metadata_open<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
293    /// closes the metadata-changes section.
294    fn metadata_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
295}
296
297pub(super) fn write_full<F: FullFormatter, W: Write>(
298    fmt: &F,
299    diff: &Diff,
300    opts: &RenderOptions,
301    writer: &mut W,
302) -> std::io::Result<()> {
303    if opts.has_warnings() {
304        fmt.full_warnings(writer, opts)?;
305    }
306
307    fmt.full_count_header(writer, diff)?;
308
309    if opts.group_by_ecosystem {
310        let grouped = diff.group_by_ecosystem();
311        let breakdown = grouped.ecosystem_breakdown();
312        fmt.full_ecosystem_breakdown(writer, &breakdown)?;
313        for (ecosystem, eco_diff) in &grouped.by_ecosystem {
314            fmt.full_ecosystem_header(writer, ecosystem)?;
315            write_full_sections(
316                fmt,
317                writer,
318                &eco_diff.added,
319                &eco_diff.removed,
320                &eco_diff.changed,
321            )?;
322        }
323    } else {
324        write_full_sections(fmt, writer, &diff.added, &diff.removed, &diff.changed)?;
325    }
326
327    if !diff.edge_diffs.is_empty() {
328        fmt.edge_open(writer, diff.edge_diffs.len())?;
329        for edge in &diff.edge_diffs {
330            fmt.edge_entry(writer, diff, edge)?;
331        }
332        fmt.edge_close(writer)?;
333    }
334
335    if let Some(mc) = &diff.metadata_changed {
336        writeln!(writer)?;
337        fmt.metadata_open(writer)?;
338        if let Some((old, new)) = &mc.timestamp {
339            fmt.field_change(writer, "Timestamp", format_option(old), format_option(new))?;
340        }
341        if let Some((old, new)) = &mc.tools {
342            fmt.field_change(
343                writer,
344                "Tools",
345                &format_vec_or_none(old),
346                &format_vec_or_none(new),
347            )?;
348        }
349        if let Some((old, new)) = &mc.authors {
350            fmt.field_change(
351                writer,
352                "Authors",
353                &format_vec_or_none(old),
354                &format_vec_or_none(new),
355            )?;
356        }
357        fmt.metadata_close(writer)?;
358    }
359
360    Ok(())
361}
362
363fn write_full_sections<F: FullFormatter, W: Write>(
364    fmt: &F,
365    writer: &mut W,
366    added: &[Component],
367    removed: &[Component],
368    changed: &[ComponentChange],
369) -> std::io::Result<()> {
370    if !added.is_empty() {
371        fmt.section_open(writer, SectionKind::Added, added.len())?;
372        fmt.component_list(writer, added)?;
373        fmt.section_close(writer)?;
374    }
375    if !removed.is_empty() {
376        fmt.section_open(writer, SectionKind::Removed, removed.len())?;
377        fmt.component_list(writer, removed)?;
378        fmt.section_close(writer)?;
379    }
380    if !changed.is_empty() {
381        fmt.section_open(writer, SectionKind::Changed, changed.len())?;
382        write_changed(fmt, writer, changed)?;
383        fmt.section_close(writer)?;
384    }
385    Ok(())
386}
387
388pub(super) fn format_vec_or_none(v: &[String]) -> String {
389    if v.is_empty() {
390        "<none>".to_string()
391    } else {
392        v.join(", ")
393    }
394}
395
396#[cfg(test)]
397mod tests;