Skip to main content

sbom_diff/renderer/
csv_format.rs

1use super::{
2    format_option, format_set, format_vec_or_none, RenderOptions, Renderer, SummaryRenderer,
3};
4use crate::{Diff, FieldChange};
5use sbom_model::is_hash_algorithm_downgrade;
6use std::io::Write;
7
8/// creates a [`csv::Writer`] configured for this crate's output conventions
9/// (LF line endings, no BOM).
10fn csv_writer<W: Write>(writer: W) -> csv::Writer<W> {
11    csv::WriterBuilder::new()
12        .terminator(csv::Terminator::Any(b'\n'))
13        .from_writer(writer)
14}
15
16/// writes parser-warning rows using the `status,component,ecosystem,field,old_value,new_value`
17/// schema, shared by the full and summary renderers so both surface warnings identically.
18fn write_warning_rows<W: Write>(wtr: &mut csv::Writer<W>, opts: &RenderOptions) -> csv::Result<()> {
19    for w in &opts.old_warnings {
20        wtr.write_record(["warning", "old", "", "", w, ""])?;
21    }
22    for w in &opts.new_warnings {
23        wtr.write_record(["warning", "new", "", "", w, ""])?;
24    }
25    Ok(())
26}
27
28/// RFC 4180 CSV renderer for spreadsheets, CI dashboards, and data pipelines.
29///
30/// full output produces one row per finding with columns:
31/// `status,component,ecosystem,field,old_value,new_value`
32///
33/// summary output produces `metric,count` pairs.
34pub struct CsvRenderer;
35
36impl Renderer for CsvRenderer {
37    fn render<W: Write>(
38        &self,
39        diff: &Diff,
40        opts: &RenderOptions,
41        writer: &mut W,
42    ) -> anyhow::Result<()> {
43        let mut wtr = csv_writer(&mut *writer);
44
45        wtr.write_record([
46            "status",
47            "component",
48            "ecosystem",
49            "field",
50            "old_value",
51            "new_value",
52        ])?;
53
54        if opts.has_warnings() {
55            write_warning_rows(&mut wtr, opts)?;
56        }
57
58        for comp in &diff.added {
59            let display = comp.purl.as_deref().unwrap_or(comp.id.as_str());
60            let eco = comp.ecosystem.as_deref().unwrap_or("");
61            let ver = comp.version.as_deref().unwrap_or("");
62            wtr.write_record(["added", display, eco, "version", "", ver])?;
63        }
64
65        for comp in &diff.removed {
66            let display = comp.purl.as_deref().unwrap_or(comp.id.as_str());
67            let eco = comp.ecosystem.as_deref().unwrap_or("");
68            let ver = comp.version.as_deref().unwrap_or("");
69            wtr.write_record(["removed", display, eco, "version", ver, ""])?;
70        }
71
72        for change in &diff.changed {
73            let display = change.new.purl.as_deref().unwrap_or(change.id.as_str());
74            let eco = change.new.ecosystem.as_deref().unwrap_or("");
75            for fc in &change.changes {
76                let (field, old, new) = csv_field_change(fc, change.is_downgrade);
77                wtr.write_record(["changed", display, eco, field, &old, &new])?;
78            }
79        }
80
81        for edge in &diff.edge_diffs {
82            let parent = diff.display_name(&edge.parent);
83            for (child, kind) in &edge.added {
84                let child_name = diff.display_name(child);
85                wtr.write_record(["edge-added", parent, "", child_name, "", &kind.to_string()])?;
86            }
87            for (child, kind) in &edge.removed {
88                let child_name = diff.display_name(child);
89                wtr.write_record([
90                    "edge-removed",
91                    parent,
92                    "",
93                    child_name,
94                    &kind.to_string(),
95                    "",
96                ])?;
97            }
98            for (child, (old_kind, new_kind)) in &edge.kind_changed {
99                let child_name = diff.display_name(child);
100                wtr.write_record([
101                    "edge-kind-changed",
102                    parent,
103                    "",
104                    child_name,
105                    &old_kind.to_string(),
106                    &new_kind.to_string(),
107                ])?;
108            }
109        }
110
111        if let Some(mc) = &diff.metadata_changed {
112            if let Some((ref old, ref new)) = mc.timestamp {
113                wtr.write_record([
114                    "metadata",
115                    "",
116                    "",
117                    "timestamp",
118                    old.as_deref().unwrap_or(""),
119                    new.as_deref().unwrap_or(""),
120                ])?;
121            }
122            if let Some((ref old, ref new)) = mc.tools {
123                wtr.write_record([
124                    "metadata",
125                    "",
126                    "",
127                    "tools",
128                    &format_vec_or_none(old),
129                    &format_vec_or_none(new),
130                ])?;
131            }
132            if let Some((ref old, ref new)) = mc.authors {
133                wtr.write_record([
134                    "metadata",
135                    "",
136                    "",
137                    "authors",
138                    &format_vec_or_none(old),
139                    &format_vec_or_none(new),
140                ])?;
141            }
142        }
143
144        wtr.flush()?;
145        Ok(())
146    }
147}
148
149impl SummaryRenderer for CsvRenderer {
150    fn render_summary<W: Write>(
151        &self,
152        diff: &Diff,
153        opts: &RenderOptions,
154        writer: &mut W,
155    ) -> anyhow::Result<()> {
156        let meta_changed = if diff.metadata_changed.is_some() {
157            "1"
158        } else {
159            "0"
160        };
161
162        // warnings are emitted as a leading block sharing the full renderer's
163        // schema, kept separate from the `metric,count` table (which has a
164        // different column count) by a blank line, like the ecosystem breakdown.
165        if opts.has_warnings() {
166            let mut wtr = csv_writer(&mut *writer);
167            wtr.write_record([
168                "status",
169                "component",
170                "ecosystem",
171                "field",
172                "old_value",
173                "new_value",
174            ])?;
175            write_warning_rows(&mut wtr, opts)?;
176            wtr.flush()?;
177            drop(wtr);
178            writeln!(writer)?;
179        }
180
181        let mut wtr = csv_writer(&mut *writer);
182        wtr.write_record(["metric", "count"])?;
183        wtr.write_record(["old_total", &diff.old_total.to_string()])?;
184        wtr.write_record(["new_total", &diff.new_total.to_string()])?;
185        wtr.write_record(["unchanged", &diff.unchanged.to_string()])?;
186        wtr.write_record(["added", &diff.added.len().to_string()])?;
187        wtr.write_record(["removed", &diff.removed.len().to_string()])?;
188        wtr.write_record(["changed", &diff.changed.len().to_string()])?;
189        wtr.write_record(["edge_changes", &diff.edge_diffs.len().to_string()])?;
190        wtr.write_record(["metadata_changed", meta_changed])?;
191
192        wtr.flush()?;
193        drop(wtr);
194
195        if opts.group_by_ecosystem {
196            let breakdown = diff.ecosystem_breakdown();
197            if !breakdown.is_empty() {
198                writeln!(writer)?;
199                let mut wtr = csv_writer(&mut *writer);
200                wtr.write_record(["ecosystem", "added", "removed", "changed"])?;
201                for (eco, counts) in &breakdown {
202                    wtr.write_record([
203                        eco.as_str(),
204                        &counts.added.to_string(),
205                        &counts.removed.to_string(),
206                        &counts.changed.to_string(),
207                    ])?;
208                }
209                wtr.flush()?;
210            }
211        }
212
213        Ok(())
214    }
215}
216
217/// converts a [`FieldChange`] into `(field_name, old_value, new_value)` for CSV output.
218fn csv_field_change(fc: &FieldChange, is_downgrade: bool) -> (&'static str, String, String) {
219    match fc {
220        FieldChange::Version(old, new) => (
221            if is_downgrade {
222                "version-downgrade"
223            } else {
224                "version"
225            },
226            format_option(old).to_string(),
227            format_option(new).to_string(),
228        ),
229        FieldChange::License(old, new) => ("license", format_set(old), format_set(new)),
230        FieldChange::Supplier(old, new) => (
231            "supplier",
232            format_option(old).to_string(),
233            format_option(new).to_string(),
234        ),
235        FieldChange::Purl(old, new) => (
236            "purl",
237            format_option(old).to_string(),
238            format_option(new).to_string(),
239        ),
240        FieldChange::Description(old, new) => (
241            "description",
242            format_option(old).to_string(),
243            format_option(new).to_string(),
244        ),
245        FieldChange::Hashes(old, new) => {
246            let old_str = old
247                .iter()
248                .map(|(k, v)| format!("{}={}", k, v))
249                .collect::<Vec<_>>()
250                .join("; ");
251            let new_str = new
252                .iter()
253                .map(|(k, v)| format!("{}={}", k, v))
254                .collect::<Vec<_>>()
255                .join("; ");
256            let field = if is_hash_algorithm_downgrade(old, new) {
257                "hashes-downgrade"
258            } else {
259                "hashes"
260            };
261            (field, old_str, new_str)
262        }
263        FieldChange::Ecosystem(old, new) => (
264            "ecosystem",
265            format_option(old).to_string(),
266            format_option(new).to_string(),
267        ),
268    }
269}