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