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
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) -> 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::Supplier(old, new) => {
152                fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
153            }
154            FieldChange::Purl(old, new) => {
155                fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
156            }
157            FieldChange::Description(old, new) => {
158                fmt.field_change(
159                    writer,
160                    "Description",
161                    format_option(old),
162                    format_option(new),
163                )?;
164            }
165            FieldChange::Hashes(old, new) => {
166                fmt.hash_header(writer)?;
167                for (algo, digest) in old {
168                    if !new.contains_key(algo) {
169                        fmt.hash_removed(writer, algo, digest)?;
170                    } else if new[algo] != *digest {
171                        fmt.hash_changed(writer, algo, digest, &new[algo])?;
172                    }
173                }
174                for (algo, digest) in new {
175                    if !old.contains_key(algo) {
176                        fmt.hash_added(writer, algo, digest)?;
177                    }
178                }
179            }
180            FieldChange::Ecosystem(old, new) => {
181                fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
182            }
183        }
184    }
185    Ok(())
186}
187
188pub(super) fn write_changed<F: FieldChangeFormatter, W: Write>(
189    fmt: &F,
190    writer: &mut W,
191    changes: &[ComponentChange],
192) -> std::io::Result<()> {
193    for c in changes {
194        fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
195        write_field_changes(fmt, writer, &c.changes, c.is_downgrade)?;
196    }
197    Ok(())
198}
199
200/// format-specific building blocks for summary output.
201///
202/// text and markdown renderers implement this trait; the shared
203/// [`write_summary`] function orchestrates calls in the correct order.
204/// JSON uses a fundamentally different approach (building a single
205/// serializable value) and implements [`SummaryRenderer`] directly.
206pub(super) trait SummaryFormatter {
207    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
208    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
209    fn write_ecosystem_breakdown<W: Write>(
210        &self,
211        w: &mut W,
212        breakdown: &BTreeMap<String, EcosystemCounts>,
213    ) -> std::io::Result<()>;
214}
215
216pub(super) fn write_summary<F: SummaryFormatter, W: Write>(
217    fmt: &F,
218    diff: &Diff,
219    opts: &RenderOptions,
220    writer: &mut W,
221) -> std::io::Result<()> {
222    if opts.has_warnings() {
223        fmt.write_warnings(writer, opts)?;
224    }
225    fmt.write_counts(writer, diff)?;
226    if opts.group_by_ecosystem {
227        let breakdown = diff.ecosystem_breakdown();
228        if !breakdown.is_empty() {
229            fmt.write_ecosystem_breakdown(writer, &breakdown)?;
230        }
231    }
232    Ok(())
233}
234
235pub(super) fn format_vec_or_none(v: &[String]) -> String {
236    if v.is_empty() {
237        "<none>".to_string()
238    } else {
239        v.join(", ")
240    }
241}
242
243#[cfg(test)]
244mod tests;