Skip to main content

sbom_diff/
renderer.rs

1//! Output renderers for displaying SBOM diffs.
2//!
3//! This module provides formatters for different output contexts:
4//!
5//! - [`TextRenderer`] - Plain text for terminal output
6//! - [`MarkdownRenderer`] - GitHub-flavored markdown for PR comments
7//! - [`JsonRenderer`] - Machine-readable JSON for tooling integration
8
9use crate::{Diff, FieldChange};
10use std::io::Write;
11
12/// Trait for rendering a [`Diff`] to an output stream.
13pub trait Renderer {
14    /// Writes the formatted diff to the provided writer.
15    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()>;
16}
17
18/// Plain text renderer for terminal output.
19pub struct TextRenderer;
20
21impl Renderer for TextRenderer {
22    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
23        writeln!(writer, "Diff Summary")?;
24        writeln!(writer, "============")?;
25        writeln!(writer, "Added:   {}", diff.added.len())?;
26        writeln!(writer, "Removed: {}", diff.removed.len())?;
27        writeln!(writer, "Changed: {}", diff.changed.len())?;
28        writeln!(writer)?;
29
30        if !diff.added.is_empty() {
31            writeln!(writer, "[+] Added")?;
32            writeln!(writer, "---------")?;
33            for c in &diff.added {
34                writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
35            }
36            writeln!(writer)?;
37        }
38
39        if !diff.removed.is_empty() {
40            writeln!(writer, "[-] Removed")?;
41            writeln!(writer, "-----------")?;
42            for c in &diff.removed {
43                writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
44            }
45            writeln!(writer)?;
46        }
47
48        if !diff.changed.is_empty() {
49            writeln!(writer, "[~] Changed")?;
50            writeln!(writer, "-----------")?;
51            for c in &diff.changed {
52                writeln!(writer, "{}", c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
53                for change in &c.changes {
54                    match change {
55                        FieldChange::Version(old, new) => {
56                            writeln!(writer, "  Version: {} -> {}", old, new)?;
57                        }
58                        FieldChange::License(old, new) => {
59                            writeln!(writer, "  License: {:?} -> {:?}", old, new)?;
60                        }
61                        FieldChange::Supplier(old, new) => {
62                            writeln!(writer, "  Supplier: {:?} -> {:?}", old, new)?;
63                        }
64                        FieldChange::Purl(old, new) => {
65                            writeln!(writer, "  Purl: {:?} -> {:?}", old, new)?;
66                        }
67                        FieldChange::Hashes => {
68                            writeln!(writer, "  Hashes: changed")?;
69                        }
70                    }
71                }
72            }
73            writeln!(writer)?;
74        }
75
76        if !diff.edge_diffs.is_empty() {
77            writeln!(writer, "[~] Edge Changes")?;
78            writeln!(writer, "----------------")?;
79            for edge in &diff.edge_diffs {
80                writeln!(writer, "{}", edge.parent)?;
81                for removed in &edge.removed {
82                    writeln!(writer, "  - {}", removed)?;
83                }
84                for added in &edge.added {
85                    writeln!(writer, "  + {}", added)?;
86                }
87            }
88        }
89
90        Ok(())
91    }
92}
93
94/// GitHub-flavored markdown renderer for PR comments.
95///
96/// Produces collapsible sections using `<details>` tags.
97pub struct MarkdownRenderer;
98
99impl Renderer for MarkdownRenderer {
100    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
101        writeln!(writer, "### SBOM Diff Summary")?;
102        writeln!(writer)?;
103        writeln!(writer, "| Change | Count |")?;
104        writeln!(writer, "| --- | --- |")?;
105        writeln!(writer, "| Added | {} |", diff.added.len())?;
106        writeln!(writer, "| Removed | {} |", diff.removed.len())?;
107        writeln!(writer, "| Changed | {} |", diff.changed.len())?;
108        writeln!(writer)?;
109
110        if !diff.added.is_empty() {
111            writeln!(
112                writer,
113                "<details><summary><b>Added ({})</b></summary>",
114                diff.added.len()
115            )?;
116            writeln!(writer)?;
117            for c in &diff.added {
118                writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
119            }
120            writeln!(writer, "</details>")?;
121            writeln!(writer)?;
122        }
123
124        if !diff.removed.is_empty() {
125            writeln!(
126                writer,
127                "<details><summary><b>Removed ({})</b></summary>",
128                diff.removed.len()
129            )?;
130            writeln!(writer)?;
131            for c in &diff.removed {
132                writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
133            }
134            writeln!(writer, "</details>")?;
135            writeln!(writer)?;
136        }
137
138        if !diff.changed.is_empty() {
139            writeln!(
140                writer,
141                "<details><summary><b>Changed ({})</b></summary>",
142                diff.changed.len()
143            )?;
144            writeln!(writer)?;
145            for c in &diff.changed {
146                writeln!(
147                    writer,
148                    "#### `{}`",
149                    c.new.purl.as_deref().unwrap_or(c.id.as_str())
150                )?;
151                for change in &c.changes {
152                    match change {
153                        FieldChange::Version(old, new) => {
154                            writeln!(writer, "- **Version**: `{}` &rarr; `{}`", old, new)?;
155                        }
156                        FieldChange::License(old, new) => {
157                            writeln!(writer, "- **License**: `{:?}` &rarr; `{:?}`", old, new)?;
158                        }
159                        FieldChange::Supplier(old, new) => {
160                            writeln!(writer, "- **Supplier**: `{:?}` &rarr; `{:?}`", old, new)?;
161                        }
162                        FieldChange::Purl(old, new) => {
163                            writeln!(writer, "- **Purl**: `{:?}` &rarr; `{:?}`", old, new)?;
164                        }
165                        FieldChange::Hashes => {
166                            writeln!(writer, "- **Hashes**: changed")?;
167                        }
168                    }
169                }
170            }
171            writeln!(writer, "</details>")?;
172            writeln!(writer)?;
173        }
174
175        if !diff.edge_diffs.is_empty() {
176            writeln!(
177                writer,
178                "<details><summary><b>Edge Changes ({})</b></summary>",
179                diff.edge_diffs.len()
180            )?;
181            writeln!(writer)?;
182            for edge in &diff.edge_diffs {
183                writeln!(writer, "#### `{}`", edge.parent)?;
184                if !edge.removed.is_empty() {
185                    writeln!(writer, "**Removed dependencies:**")?;
186                    for removed in &edge.removed {
187                        writeln!(writer, "- `{}`", removed)?;
188                    }
189                }
190                if !edge.added.is_empty() {
191                    writeln!(writer, "**Added dependencies:**")?;
192                    for added in &edge.added {
193                        writeln!(writer, "- `{}`", added)?;
194                    }
195                }
196                writeln!(writer)?;
197            }
198            writeln!(writer, "</details>")?;
199        }
200
201        Ok(())
202    }
203}
204
205/// JSON renderer for machine consumption.
206///
207/// Outputs the [`Diff`] struct as pretty-printed JSON.
208pub struct JsonRenderer;
209
210impl Renderer for JsonRenderer {
211    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
212        serde_json::to_writer_pretty(writer, diff)?;
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::{ComponentChange, Diff, FieldChange};
221    use sbom_model::Component;
222
223    fn mock_diff() -> Diff {
224        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
225        let mut c2 = c1.clone();
226        c2.version = Some("1.1".into());
227
228        Diff {
229            added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
230            removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
231            changed: vec![ComponentChange {
232                id: c2.id.clone(),
233                old: c1,
234                new: c2,
235                changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
236            }],
237            edge_diffs: vec![],
238            metadata_changed: false,
239        }
240    }
241
242    fn mock_diff_all_field_changes() -> Diff {
243        use sbom_model::ComponentId;
244        use std::collections::BTreeSet;
245
246        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
247        let mut c2 = c1.clone();
248        c2.version = Some("1.1".into());
249
250        Diff {
251            added: vec![],
252            removed: vec![],
253            changed: vec![ComponentChange {
254                id: c2.id.clone(),
255                old: c1,
256                new: c2,
257                changes: vec![
258                    FieldChange::Version("1.0".into(), "1.1".into()),
259                    FieldChange::License(
260                        BTreeSet::from(["MIT".into()]),
261                        BTreeSet::from(["Apache-2.0".into()]),
262                    ),
263                    FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
264                    FieldChange::Purl(
265                        Some("pkg:npm/pkg-a@1.0".into()),
266                        Some("pkg:npm/pkg-a@1.1".into()),
267                    ),
268                    FieldChange::Hashes,
269                ],
270            }],
271            edge_diffs: vec![crate::EdgeDiff {
272                parent: ComponentId::new(None, &[("name", "parent")]),
273                added: BTreeSet::from([ComponentId::new(None, &[("name", "child-b")])]),
274                removed: BTreeSet::from([ComponentId::new(None, &[("name", "child-a")])]),
275            }],
276            metadata_changed: false,
277        }
278    }
279
280    fn mock_diff_empty() -> Diff {
281        Diff {
282            added: vec![],
283            removed: vec![],
284            changed: vec![],
285            edge_diffs: vec![],
286            metadata_changed: false,
287        }
288    }
289
290    #[test]
291    fn test_text_renderer() {
292        let diff = mock_diff();
293        let mut buf = Vec::new();
294        TextRenderer.render(&diff, &mut buf).unwrap();
295        let out = String::from_utf8(buf).unwrap();
296        assert!(out.contains("Diff Summary"));
297        assert!(out.contains("[+] Added"));
298        assert!(out.contains("[-] Removed"));
299        assert!(out.contains("[~] Changed"));
300    }
301
302    #[test]
303    fn test_text_renderer_all_field_changes() {
304        let diff = mock_diff_all_field_changes();
305        let mut buf = Vec::new();
306        TextRenderer.render(&diff, &mut buf).unwrap();
307        let out = String::from_utf8(buf).unwrap();
308
309        assert!(out.contains("Version: 1.0 -> 1.1"));
310        assert!(out.contains("License:"));
311        assert!(out.contains("MIT"));
312        assert!(out.contains("Apache-2.0"));
313        assert!(out.contains("Supplier:"));
314        assert!(out.contains("Old Corp"));
315        assert!(out.contains("New Corp"));
316        assert!(out.contains("Purl:"));
317        assert!(out.contains("Hashes: changed"));
318        assert!(out.contains("[~] Edge Changes"));
319    }
320
321    #[test]
322    fn test_text_renderer_empty_diff() {
323        let diff = mock_diff_empty();
324        let mut buf = Vec::new();
325        TextRenderer.render(&diff, &mut buf).unwrap();
326        let out = String::from_utf8(buf).unwrap();
327
328        assert!(out.contains("Added:   0"));
329        assert!(out.contains("Removed: 0"));
330        assert!(out.contains("Changed: 0"));
331        assert!(!out.contains("[+] Added"));
332        assert!(!out.contains("[-] Removed"));
333        assert!(!out.contains("[~] Changed"));
334    }
335
336    #[test]
337    fn test_markdown_renderer() {
338        let diff = mock_diff();
339        let mut buf = Vec::new();
340        MarkdownRenderer.render(&diff, &mut buf).unwrap();
341        let out = String::from_utf8(buf).unwrap();
342        assert!(out.contains("### SBOM Diff Summary"));
343        assert!(out.contains("<details>"));
344    }
345
346    #[test]
347    fn test_markdown_renderer_all_field_changes() {
348        let diff = mock_diff_all_field_changes();
349        let mut buf = Vec::new();
350        MarkdownRenderer.render(&diff, &mut buf).unwrap();
351        let out = String::from_utf8(buf).unwrap();
352
353        assert!(out.contains("**Version**"));
354        assert!(out.contains("**License**"));
355        assert!(out.contains("**Supplier**"));
356        assert!(out.contains("**Purl**"));
357        assert!(out.contains("**Hashes**: changed"));
358        assert!(out.contains("Edge Changes"));
359        assert!(out.contains("**Removed dependencies:**"));
360        assert!(out.contains("**Added dependencies:**"));
361    }
362
363    #[test]
364    fn test_markdown_renderer_empty_diff() {
365        let diff = mock_diff_empty();
366        let mut buf = Vec::new();
367        MarkdownRenderer.render(&diff, &mut buf).unwrap();
368        let out = String::from_utf8(buf).unwrap();
369
370        assert!(out.contains("| Added | 0 |"));
371        assert!(!out.contains("<details>"));
372    }
373
374    #[test]
375    fn test_json_renderer() {
376        let diff = mock_diff();
377        let mut buf = Vec::new();
378        JsonRenderer.render(&diff, &mut buf).unwrap();
379        let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
380    }
381
382    #[test]
383    fn test_json_renderer_all_field_changes() {
384        let diff = mock_diff_all_field_changes();
385        let mut buf = Vec::new();
386        JsonRenderer.render(&diff, &mut buf).unwrap();
387        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
388
389        assert_eq!(val["changed"].as_array().unwrap().len(), 1);
390        assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 5);
391        assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
392    }
393
394    #[test]
395    fn test_json_renderer_roundtrip() {
396        let diff = mock_diff_all_field_changes();
397        let mut buf = Vec::new();
398        JsonRenderer.render(&diff, &mut buf).unwrap();
399
400        let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
401        assert_eq!(deserialized.changed.len(), diff.changed.len());
402        assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
403        assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
404    }
405}