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        }
74
75        Ok(())
76    }
77}
78
79/// GitHub-flavored markdown renderer for PR comments.
80///
81/// Produces collapsible sections using `<details>` tags.
82pub struct MarkdownRenderer;
83
84impl Renderer for MarkdownRenderer {
85    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
86        writeln!(writer, "### SBOM Diff Summary")?;
87        writeln!(writer)?;
88        writeln!(writer, "| Change | Count |")?;
89        writeln!(writer, "| --- | --- |")?;
90        writeln!(writer, "| Added | {} |", diff.added.len())?;
91        writeln!(writer, "| Removed | {} |", diff.removed.len())?;
92        writeln!(writer, "| Changed | {} |", diff.changed.len())?;
93        writeln!(writer)?;
94
95        if !diff.added.is_empty() {
96            writeln!(
97                writer,
98                "<details><summary><b>Added ({})</b></summary>",
99                diff.added.len()
100            )?;
101            writeln!(writer)?;
102            for c in &diff.added {
103                writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
104            }
105            writeln!(writer, "</details>")?;
106            writeln!(writer)?;
107        }
108
109        if !diff.removed.is_empty() {
110            writeln!(
111                writer,
112                "<details><summary><b>Removed ({})</b></summary>",
113                diff.removed.len()
114            )?;
115            writeln!(writer)?;
116            for c in &diff.removed {
117                writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
118            }
119            writeln!(writer, "</details>")?;
120            writeln!(writer)?;
121        }
122
123        if !diff.changed.is_empty() {
124            writeln!(
125                writer,
126                "<details><summary><b>Changed ({})</b></summary>",
127                diff.changed.len()
128            )?;
129            writeln!(writer)?;
130            for c in &diff.changed {
131                writeln!(
132                    writer,
133                    "#### `{}`",
134                    c.new.purl.as_deref().unwrap_or(c.id.as_str())
135                )?;
136                for change in &c.changes {
137                    match change {
138                        FieldChange::Version(old, new) => {
139                            writeln!(writer, "- **Version**: `{}` &rarr; `{}`", old, new)?;
140                        }
141                        FieldChange::License(old, new) => {
142                            writeln!(writer, "- **License**: `{:?}` &rarr; `{:?}`", old, new)?;
143                        }
144                        FieldChange::Supplier(old, new) => {
145                            writeln!(writer, "- **Supplier**: `{:?}` &rarr; `{:?}`", old, new)?;
146                        }
147                        FieldChange::Purl(old, new) => {
148                            writeln!(writer, "- **Purl**: `{:?}` &rarr; `{:?}`", old, new)?;
149                        }
150                        FieldChange::Hashes => {
151                            writeln!(writer, "- **Hashes**: changed")?;
152                        }
153                    }
154                }
155            }
156            writeln!(writer, "</details>")?;
157        }
158
159        Ok(())
160    }
161}
162
163/// JSON renderer for machine consumption.
164///
165/// Outputs the [`Diff`] struct as pretty-printed JSON.
166pub struct JsonRenderer;
167
168impl Renderer for JsonRenderer {
169    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
170        serde_json::to_writer_pretty(writer, diff)?;
171        Ok(())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::{ComponentChange, Diff, FieldChange};
179    use sbom_model::Component;
180
181    fn mock_diff() -> Diff {
182        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
183        let mut c2 = c1.clone();
184        c2.version = Some("1.1".into());
185
186        Diff {
187            added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
188            removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
189            changed: vec![ComponentChange {
190                id: c2.id.clone(),
191                old: c1,
192                new: c2,
193                changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
194            }],
195            metadata_changed: false,
196        }
197    }
198
199    #[test]
200    fn test_text_renderer() {
201        let diff = mock_diff();
202        let mut buf = Vec::new();
203        TextRenderer.render(&diff, &mut buf).unwrap();
204        let out = String::from_utf8(buf).unwrap();
205        assert!(out.contains("Diff Summary"));
206        assert!(out.contains("[+] Added"));
207        assert!(out.contains("[-] Removed"));
208        assert!(out.contains("[~] Changed"));
209    }
210
211    #[test]
212    fn test_markdown_renderer() {
213        let diff = mock_diff();
214        let mut buf = Vec::new();
215        MarkdownRenderer.render(&diff, &mut buf).unwrap();
216        let out = String::from_utf8(buf).unwrap();
217        assert!(out.contains("### SBOM Diff Summary"));
218        assert!(out.contains("<details>"));
219    }
220
221    #[test]
222    fn test_json_renderer() {
223        let diff = mock_diff();
224        let mut buf = Vec::new();
225        JsonRenderer.render(&diff, &mut buf).unwrap();
226        let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
227    }
228}