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
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
17/// GitHub-flavored markdown renderer for PR comments.
18///
19/// produces collapsible sections using `<details>` tags.
20pub 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, "- **{}**: `{}` &rarr; `{}`", 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, "  - `{}`: `{}` &rarr; `{}`", 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                            "- `{}`: {} &rarr; {}",
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**: `{}` &rarr; `{}`",
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**: `{}` &rarr; `{}`",
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**: `{}` &rarr; `{}`",
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}