1mod 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#[derive(Debug, Clone, Default)]
30pub struct RenderOptions {
31 pub group_by_ecosystem: bool,
33 pub show_warnings: bool,
35 pub old_warnings: Vec<String>,
37 pub new_warnings: Vec<String>,
39}
40
41impl RenderOptions {
42 pub fn has_warnings(&self) -> bool {
44 self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
45 }
46
47 pub fn warning_count(&self) -> usize {
49 self.old_warnings.len() + self.new_warnings.len()
50 }
51}
52
53pub(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
66pub fn format_option(opt: &Option<String>) -> &str {
68 opt.as_deref().unwrap_or("<none>")
69}
70
71pub 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
87pub trait Renderer {
89 fn render<W: Write>(
91 &self,
92 diff: &Diff,
93 opts: &RenderOptions,
94 writer: &mut W,
95 ) -> anyhow::Result<()>;
96}
97
98pub trait SummaryRenderer {
102 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::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, is_hash_algorithm_downgrade(old, new))?;
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
200pub(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
235#[derive(Clone, Copy)]
239pub(super) enum SectionKind {
240 Added,
241 Removed,
242 Changed,
243}
244
245pub(super) trait FullFormatter: FieldChangeFormatter {
254 fn full_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
256 fn full_count_header<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
258 fn full_ecosystem_breakdown<W: Write>(
260 &self,
261 w: &mut W,
262 breakdown: &BTreeMap<String, EcosystemCounts>,
263 ) -> std::io::Result<()>;
264 fn full_ecosystem_header<W: Write>(&self, w: &mut W, ecosystem: &str) -> std::io::Result<()>;
266 fn section_open<W: Write>(
268 &self,
269 w: &mut W,
270 kind: SectionKind,
271 count: usize,
272 ) -> std::io::Result<()>;
273 fn section_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
275 fn component_list<W: Write>(&self, w: &mut W, components: &[Component]) -> std::io::Result<()>;
277 fn edge_open<W: Write>(&self, w: &mut W, count: usize) -> std::io::Result<()>;
279 fn edge_entry<W: Write>(&self, w: &mut W, diff: &Diff, edge: &EdgeDiff) -> std::io::Result<()>;
281 fn edge_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
283 fn metadata_open<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
285 fn metadata_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
287}
288
289pub(super) fn write_full<F: FullFormatter, W: Write>(
290 fmt: &F,
291 diff: &Diff,
292 opts: &RenderOptions,
293 writer: &mut W,
294) -> std::io::Result<()> {
295 if opts.has_warnings() {
296 fmt.full_warnings(writer, opts)?;
297 }
298
299 fmt.full_count_header(writer, diff)?;
300
301 if opts.group_by_ecosystem {
302 let grouped = diff.group_by_ecosystem();
303 let breakdown = grouped.ecosystem_breakdown();
304 fmt.full_ecosystem_breakdown(writer, &breakdown)?;
305 for (ecosystem, eco_diff) in &grouped.by_ecosystem {
306 fmt.full_ecosystem_header(writer, ecosystem)?;
307 write_full_sections(
308 fmt,
309 writer,
310 &eco_diff.added,
311 &eco_diff.removed,
312 &eco_diff.changed,
313 )?;
314 }
315 } else {
316 write_full_sections(fmt, writer, &diff.added, &diff.removed, &diff.changed)?;
317 }
318
319 if !diff.edge_diffs.is_empty() {
320 fmt.edge_open(writer, diff.edge_diffs.len())?;
321 for edge in &diff.edge_diffs {
322 fmt.edge_entry(writer, diff, edge)?;
323 }
324 fmt.edge_close(writer)?;
325 }
326
327 if let Some(mc) = &diff.metadata_changed {
328 writeln!(writer)?;
329 fmt.metadata_open(writer)?;
330 if let Some((old, new)) = &mc.timestamp {
331 fmt.field_change(writer, "Timestamp", format_option(old), format_option(new))?;
332 }
333 if let Some((old, new)) = &mc.tools {
334 fmt.field_change(
335 writer,
336 "Tools",
337 &format_vec_or_none(old),
338 &format_vec_or_none(new),
339 )?;
340 }
341 if let Some((old, new)) = &mc.authors {
342 fmt.field_change(
343 writer,
344 "Authors",
345 &format_vec_or_none(old),
346 &format_vec_or_none(new),
347 )?;
348 }
349 fmt.metadata_close(writer)?;
350 }
351
352 Ok(())
353}
354
355fn write_full_sections<F: FullFormatter, W: Write>(
356 fmt: &F,
357 writer: &mut W,
358 added: &[Component],
359 removed: &[Component],
360 changed: &[ComponentChange],
361) -> std::io::Result<()> {
362 if !added.is_empty() {
363 fmt.section_open(writer, SectionKind::Added, added.len())?;
364 fmt.component_list(writer, added)?;
365 fmt.section_close(writer)?;
366 }
367 if !removed.is_empty() {
368 fmt.section_open(writer, SectionKind::Removed, removed.len())?;
369 fmt.component_list(writer, removed)?;
370 fmt.section_close(writer)?;
371 }
372 if !changed.is_empty() {
373 fmt.section_open(writer, SectionKind::Changed, changed.len())?;
374 write_changed(fmt, writer, changed)?;
375 fmt.section_close(writer)?;
376 }
377 Ok(())
378}
379
380pub(super) fn format_vec_or_none(v: &[String]) -> String {
381 if v.is_empty() {
382 "<none>".to_string()
383 } else {
384 v.join(", ")
385 }
386}
387
388#[cfg(test)]
389mod tests;