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::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
208pub(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#[derive(Clone, Copy)]
247pub(super) enum SectionKind {
248 Added,
249 Removed,
250 Changed,
251}
252
253pub(super) trait FullFormatter: FieldChangeFormatter {
262 fn full_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
264 fn full_count_header<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
266 fn full_ecosystem_breakdown<W: Write>(
268 &self,
269 w: &mut W,
270 breakdown: &BTreeMap<String, EcosystemCounts>,
271 ) -> std::io::Result<()>;
272 fn full_ecosystem_header<W: Write>(&self, w: &mut W, ecosystem: &str) -> std::io::Result<()>;
274 fn section_open<W: Write>(
276 &self,
277 w: &mut W,
278 kind: SectionKind,
279 count: usize,
280 ) -> std::io::Result<()>;
281 fn section_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
283 fn component_list<W: Write>(&self, w: &mut W, components: &[Component]) -> std::io::Result<()>;
285 fn edge_open<W: Write>(&self, w: &mut W, count: usize) -> std::io::Result<()>;
287 fn edge_entry<W: Write>(&self, w: &mut W, diff: &Diff, edge: &EdgeDiff) -> std::io::Result<()>;
289 fn edge_close<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
291 fn metadata_open<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
293 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;