Skip to main content

sbom_diff/renderer/
markdown.rs

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
10// --- Markdown output helpers ---
11
12fn 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
19/// GitHub-flavored markdown renderer for PR comments.
20///
21/// produces collapsible sections using `<details>` tags.
22pub 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, "- **{}**: `{}` &rarr; `{}`", 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, "  - `{}`: `{}` &rarr; `{}`", 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                            "- `{}`: {} &rarr; {}",
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**: `{}` &rarr; `{}`",
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**: `{}` &rarr; `{}`",
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**: `{}` &rarr; `{}`",
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}