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, FieldChange};
24use sbom_model::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) -> 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
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
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;