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