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    #[test]
243    fn test_text_renderer() {
244        let diff = mock_diff();
245        let mut buf = Vec::new();
246        TextRenderer.render(&diff, &mut buf).unwrap();
247        let out = String::from_utf8(buf).unwrap();
248        assert!(out.contains("Diff Summary"));
249        assert!(out.contains("[+] Added"));
250        assert!(out.contains("[-] Removed"));
251        assert!(out.contains("[~] Changed"));
252    }
253
254    #[test]
255    fn test_markdown_renderer() {
256        let diff = mock_diff();
257        let mut buf = Vec::new();
258        MarkdownRenderer.render(&diff, &mut buf).unwrap();
259        let out = String::from_utf8(buf).unwrap();
260        assert!(out.contains("### SBOM Diff Summary"));
261        assert!(out.contains("<details>"));
262    }
263
264    #[test]
265    fn test_json_renderer() {
266        let diff = mock_diff();
267        let mut buf = Vec::new();
268        JsonRenderer.render(&diff, &mut buf).unwrap();
269        let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
270    }
271}