1use super::{
2 format_vec_or_none, kind_suffix, write_changed, write_summary, FieldChangeFormatter,
3 RenderOptions, Renderer, SummaryFormatter, SummaryRenderer,
4};
5use crate::{Diff, EcosystemCounts, MetadataChange};
6use sbom_model::Component;
7use std::collections::BTreeMap;
8use std::io::Write;
9
10fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
13 for c in components {
14 writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
15 }
16 Ok(())
17}
18
19pub struct MarkdownRenderer;
23
24impl FieldChangeFormatter for MarkdownRenderer {
25 fn field_change<W: Write>(
26 &self,
27 w: &mut W,
28 name: &str,
29 old: &str,
30 new: &str,
31 ) -> std::io::Result<()> {
32 writeln!(w, "- **{}**: `{}` → `{}`", name, old, new)
33 }
34
35 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
36 writeln!(w, "- **Hashes**:")
37 }
38
39 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
40 writeln!(w, " - `{}`: removed `{}`", algo, digest)
41 }
42
43 fn hash_changed<W: Write>(
44 &self,
45 w: &mut W,
46 algo: &str,
47 old: &str,
48 new: &str,
49 ) -> std::io::Result<()> {
50 writeln!(w, " - `{}`: `{}` → `{}`", algo, old, new)
51 }
52
53 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
54 writeln!(w, " - `{}`: added `{}`", algo, digest)
55 }
56
57 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
58 writeln!(w, "#### `{}`", id)
59 }
60}
61
62impl Renderer for MarkdownRenderer {
63 fn render<W: Write>(
64 &self,
65 diff: &Diff,
66 opts: &RenderOptions,
67 writer: &mut W,
68 ) -> anyhow::Result<()> {
69 if opts.has_warnings() {
70 writeln!(
71 writer,
72 "<details><summary><b>Warnings ({})</b></summary>",
73 opts.warning_count()
74 )?;
75 writeln!(writer)?;
76 for w in &opts.old_warnings {
77 writeln!(writer, "- **old:** {}", w)?;
78 }
79 for w in &opts.new_warnings {
80 writeln!(writer, "- **new:** {}", w)?;
81 }
82 writeln!(writer, "</details>")?;
83 writeln!(writer)?;
84 }
85
86 writeln!(writer, "### SBOM Diff Summary")?;
87 writeln!(writer)?;
88 writeln!(writer, "| Metric | Count |")?;
89 writeln!(writer, "| --- | --- |")?;
90 writeln!(writer, "| Old total | {} |", diff.old_total)?;
91 writeln!(writer, "| New total | {} |", diff.new_total)?;
92 writeln!(writer, "| Unchanged | {} |", diff.unchanged)?;
93 writeln!(writer, "| Added | {} |", diff.added.len())?;
94 writeln!(writer, "| Removed | {} |", diff.removed.len())?;
95 writeln!(writer, "| Changed | {} |", diff.changed.len())?;
96 writeln!(writer, "| Edge changes | {} |", diff.edge_diffs.len())?;
97 writeln!(
98 writer,
99 "| Metadata changed | {} |",
100 if diff.metadata_changed.is_some() {
101 "yes"
102 } else {
103 "no"
104 }
105 )?;
106 writeln!(writer)?;
107
108 if opts.group_by_ecosystem {
109 let grouped = diff.group_by_ecosystem();
110 let breakdown = grouped.ecosystem_breakdown();
111
112 writeln!(writer, "#### By Ecosystem")?;
113 writeln!(writer)?;
114 writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
115 writeln!(writer, "| --- | --- | --- | --- |")?;
116 for (eco, counts) in &breakdown {
117 writeln!(
118 writer,
119 "| {} | {} | {} | {} |",
120 eco, counts.added, counts.removed, counts.changed
121 )?;
122 }
123 writeln!(writer)?;
124
125 for (eco, eco_diff) in &grouped.by_ecosystem {
126 writeln!(writer, "#### {}", eco)?;
127 writeln!(writer)?;
128 if !eco_diff.added.is_empty() {
129 writeln!(
130 writer,
131 "<details><summary><b>Added ({})</b></summary>",
132 eco_diff.added.len()
133 )?;
134 writeln!(writer)?;
135 write_md_added(writer, &eco_diff.added)?;
136 writeln!(writer, "</details>")?;
137 writeln!(writer)?;
138 }
139 if !eco_diff.removed.is_empty() {
140 writeln!(
141 writer,
142 "<details><summary><b>Removed ({})</b></summary>",
143 eco_diff.removed.len()
144 )?;
145 writeln!(writer)?;
146 write_md_added(writer, &eco_diff.removed)?;
147 writeln!(writer, "</details>")?;
148 writeln!(writer)?;
149 }
150 if !eco_diff.changed.is_empty() {
151 writeln!(
152 writer,
153 "<details><summary><b>Changed ({})</b></summary>",
154 eco_diff.changed.len()
155 )?;
156 writeln!(writer)?;
157 write_changed(self, writer, &eco_diff.changed)?;
158 writeln!(writer, "</details>")?;
159 writeln!(writer)?;
160 }
161 }
162 } else {
163 if !diff.added.is_empty() {
164 writeln!(
165 writer,
166 "<details><summary><b>Added ({})</b></summary>",
167 diff.added.len()
168 )?;
169 writeln!(writer)?;
170 write_md_added(writer, &diff.added)?;
171 writeln!(writer, "</details>")?;
172 writeln!(writer)?;
173 }
174
175 if !diff.removed.is_empty() {
176 writeln!(
177 writer,
178 "<details><summary><b>Removed ({})</b></summary>",
179 diff.removed.len()
180 )?;
181 writeln!(writer)?;
182 write_md_added(writer, &diff.removed)?;
183 writeln!(writer, "</details>")?;
184 writeln!(writer)?;
185 }
186
187 if !diff.changed.is_empty() {
188 writeln!(
189 writer,
190 "<details><summary><b>Changed ({})</b></summary>",
191 diff.changed.len()
192 )?;
193 writeln!(writer)?;
194 write_changed(self, writer, &diff.changed)?;
195 writeln!(writer, "</details>")?;
196 writeln!(writer)?;
197 }
198 }
199
200 if !diff.edge_diffs.is_empty() {
201 writeln!(
202 writer,
203 "<details><summary><b>Edge Changes ({})</b></summary>",
204 diff.edge_diffs.len()
205 )?;
206 writeln!(writer)?;
207 for edge in &diff.edge_diffs {
208 writeln!(writer, "#### `{}`", diff.display_name(&edge.parent))?;
209 if !edge.removed.is_empty() {
210 writeln!(writer, "**Removed dependencies:**")?;
211 for (removed, kind) in &edge.removed {
212 writeln!(
213 writer,
214 "- `{}`{}",
215 diff.display_name(removed),
216 kind_suffix(kind)
217 )?;
218 }
219 }
220 if !edge.added.is_empty() {
221 writeln!(writer, "**Added dependencies:**")?;
222 for (added, kind) in &edge.added {
223 writeln!(
224 writer,
225 "- `{}`{}",
226 diff.display_name(added),
227 kind_suffix(kind)
228 )?;
229 }
230 }
231 if !edge.kind_changed.is_empty() {
232 writeln!(writer, "**Kind changed:**")?;
233 for (changed, (old_kind, new_kind)) in &edge.kind_changed {
234 writeln!(
235 writer,
236 "- `{}`: {} → {}",
237 diff.display_name(changed),
238 old_kind,
239 new_kind
240 )?;
241 }
242 }
243 writeln!(writer)?;
244 }
245 writeln!(writer, "</details>")?;
246 }
247
248 if let Some(mc) = &diff.metadata_changed {
249 writeln!(writer)?;
250 write_md_metadata(writer, mc)?;
251 }
252
253 Ok(())
254 }
255}
256
257fn write_md_metadata<W: Write>(writer: &mut W, mc: &MetadataChange) -> std::io::Result<()> {
258 writeln!(
259 writer,
260 "<details><summary><b>Metadata Changes</b></summary>"
261 )?;
262 writeln!(writer)?;
263 if let Some((ref old, ref new)) = mc.timestamp {
264 writeln!(
265 writer,
266 "- **Timestamp**: `{}` → `{}`",
267 old.as_deref().unwrap_or("<none>"),
268 new.as_deref().unwrap_or("<none>")
269 )?;
270 }
271 if let Some((ref old, ref new)) = mc.tools {
272 writeln!(
273 writer,
274 "- **Tools**: `{}` → `{}`",
275 format_vec_or_none(old),
276 format_vec_or_none(new)
277 )?;
278 }
279 if let Some((ref old, ref new)) = mc.authors {
280 writeln!(
281 writer,
282 "- **Authors**: `{}` → `{}`",
283 format_vec_or_none(old),
284 format_vec_or_none(new)
285 )?;
286 }
287 writeln!(writer, "</details>")?;
288 Ok(())
289}
290
291impl SummaryFormatter for MarkdownRenderer {
292 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
293 writeln!(
294 w,
295 "<details><summary><b>Warnings ({})</b></summary>",
296 opts.warning_count()
297 )?;
298 writeln!(w)?;
299 for warning in &opts.old_warnings {
300 writeln!(w, "- **old:** {}", warning)?;
301 }
302 for warning in &opts.new_warnings {
303 writeln!(w, "- **new:** {}", warning)?;
304 }
305 writeln!(w, "</details>")?;
306 writeln!(w)
307 }
308
309 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
310 writeln!(w, "### SBOM Diff Summary")?;
311 writeln!(w)?;
312 writeln!(w, "| Metric | Count |")?;
313 writeln!(w, "| --- | --- |")?;
314 writeln!(w, "| Old total | {} |", diff.old_total)?;
315 writeln!(w, "| New total | {} |", diff.new_total)?;
316 writeln!(w, "| Unchanged | {} |", diff.unchanged)?;
317 writeln!(w, "| Added | {} |", diff.added.len())?;
318 writeln!(w, "| Removed | {} |", diff.removed.len())?;
319 writeln!(w, "| Changed | {} |", diff.changed.len())?;
320 writeln!(w, "| Edge changes | {} |", diff.edge_diffs.len())?;
321 writeln!(
322 w,
323 "| Metadata changed | {} |",
324 if diff.metadata_changed.is_some() {
325 "yes"
326 } else {
327 "no"
328 }
329 )
330 }
331
332 fn write_ecosystem_breakdown<W: Write>(
333 &self,
334 w: &mut W,
335 breakdown: &BTreeMap<String, EcosystemCounts>,
336 ) -> std::io::Result<()> {
337 writeln!(w)?;
338 writeln!(w, "#### By Ecosystem")?;
339 writeln!(w)?;
340 writeln!(w, "| Ecosystem | Added | Removed | Changed |")?;
341 writeln!(w, "| --- | --- | --- | --- |")?;
342 for (eco, counts) in breakdown {
343 writeln!(
344 w,
345 "| {} | {} | {} | {} |",
346 eco, counts.added, counts.removed, counts.changed
347 )?;
348 }
349 Ok(())
350 }
351}
352
353impl SummaryRenderer for MarkdownRenderer {
354 fn render_summary<W: Write>(
355 &self,
356 diff: &Diff,
357 opts: &RenderOptions,
358 writer: &mut W,
359 ) -> anyhow::Result<()> {
360 write_summary(self, diff, opts, writer)?;
361 Ok(())
362 }
363}