Skip to main content

sbom_diff/
renderer.rs

1use crate::{Diff, FieldChange};
2use std::io::Write;
3
4pub trait Renderer {
5    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()>;
6}
7
8pub struct TextRenderer;
9
10impl Renderer for TextRenderer {
11    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
12        writeln!(writer, "Diff Summary")?;
13        writeln!(writer, "============")?;
14        writeln!(writer, "Added:   {}", diff.added.len())?;
15        writeln!(writer, "Removed: {}", diff.removed.len())?;
16        writeln!(writer, "Changed: {}", diff.changed.len())?;
17        writeln!(writer)?;
18
19        if !diff.added.is_empty() {
20            writeln!(writer, "[+] Added")?;
21            writeln!(writer, "---------")?;
22            for c in &diff.added {
23                writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
24            }
25            writeln!(writer)?;
26        }
27
28        if !diff.removed.is_empty() {
29            writeln!(writer, "[-] Removed")?;
30            writeln!(writer, "-----------")?;
31            for c in &diff.removed {
32                writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
33            }
34            writeln!(writer)?;
35        }
36
37        if !diff.changed.is_empty() {
38            writeln!(writer, "[~] Changed")?;
39            writeln!(writer, "-----------")?;
40            for c in &diff.changed {
41                writeln!(writer, "{}", c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
42                for change in &c.changes {
43                    match change {
44                        FieldChange::Version(old, new) => {
45                            writeln!(writer, "  Version: {} -> {}", old, new)?;
46                        }
47                        FieldChange::License(old, new) => {
48                            writeln!(writer, "  License: {:?} -> {:?}", old, new)?;
49                        }
50                        FieldChange::Supplier(old, new) => {
51                            writeln!(writer, "  Supplier: {:?} -> {:?}", old, new)?;
52                        }
53                        FieldChange::Purl(old, new) => {
54                            writeln!(writer, "  Purl: {:?} -> {:?}", old, new)?;
55                        }
56                        FieldChange::Hashes => {
57                            writeln!(writer, "  Hashes: changed")?;
58                        }
59                    }
60                }
61            }
62        }
63
64        Ok(())
65    }
66}
67
68pub struct MarkdownRenderer;
69
70impl Renderer for MarkdownRenderer {
71    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
72        writeln!(writer, "### SBOM Diff Summary")?;
73        writeln!(writer)?;
74        writeln!(writer, "| Change | Count |")?;
75        writeln!(writer, "| --- | --- |")?;
76        writeln!(writer, "| Added | {} |", diff.added.len())?;
77        writeln!(writer, "| Removed | {} |", diff.removed.len())?;
78        writeln!(writer, "| Changed | {} |", diff.changed.len())?;
79        writeln!(writer)?;
80
81        if !diff.added.is_empty() {
82            writeln!(
83                writer,
84                "<details><summary><b>Added ({})</b></summary>",
85                diff.added.len()
86            )?;
87            writeln!(writer)?;
88            for c in &diff.added {
89                writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
90            }
91            writeln!(writer, "</details>")?;
92            writeln!(writer)?;
93        }
94
95        if !diff.removed.is_empty() {
96            writeln!(
97                writer,
98                "<details><summary><b>Removed ({})</b></summary>",
99                diff.removed.len()
100            )?;
101            writeln!(writer)?;
102            for c in &diff.removed {
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.changed.is_empty() {
110            writeln!(
111                writer,
112                "<details><summary><b>Changed ({})</b></summary>",
113                diff.changed.len()
114            )?;
115            writeln!(writer)?;
116            for c in &diff.changed {
117                writeln!(
118                    writer,
119                    "#### `{}`",
120                    c.new.purl.as_deref().unwrap_or(c.id.as_str())
121                )?;
122                for change in &c.changes {
123                    match change {
124                        FieldChange::Version(old, new) => {
125                            writeln!(writer, "- **Version**: `{}` &rarr; `{}`", old, new)?;
126                        }
127                        FieldChange::License(old, new) => {
128                            writeln!(writer, "- **License**: `{:?}` &rarr; `{:?}`", old, new)?;
129                        }
130                        FieldChange::Supplier(old, new) => {
131                            writeln!(writer, "- **Supplier**: `{:?}` &rarr; `{:?}`", old, new)?;
132                        }
133                        FieldChange::Purl(old, new) => {
134                            writeln!(writer, "- **Purl**: `{:?}` &rarr; `{:?}`", old, new)?;
135                        }
136                        FieldChange::Hashes => {
137                            writeln!(writer, "- **Hashes**: changed")?;
138                        }
139                    }
140                }
141            }
142            writeln!(writer, "</details>")?;
143        }
144
145        Ok(())
146    }
147}
148
149pub struct JsonRenderer;
150
151impl Renderer for JsonRenderer {
152    fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()> {
153        serde_json::to_writer_pretty(writer, diff)?;
154        Ok(())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::{ComponentChange, Diff, FieldChange};
162    use sbom_model::Component;
163
164    fn mock_diff() -> Diff {
165        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
166        let mut c2 = c1.clone();
167        c2.version = Some("1.1".into());
168
169        Diff {
170            added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
171            removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
172            changed: vec![ComponentChange {
173                id: c2.id.clone(),
174                old: c1,
175                new: c2,
176                changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
177            }],
178            metadata_changed: false,
179        }
180    }
181
182    #[test]
183    fn test_text_renderer() {
184        let diff = mock_diff();
185        let mut buf = Vec::new();
186        TextRenderer.render(&diff, &mut buf).unwrap();
187        let out = String::from_utf8(buf).unwrap();
188        assert!(out.contains("Diff Summary"));
189        assert!(out.contains("[+] Added"));
190        assert!(out.contains("[-] Removed"));
191        assert!(out.contains("[~] Changed"));
192    }
193
194    #[test]
195    fn test_markdown_renderer() {
196        let diff = mock_diff();
197        let mut buf = Vec::new();
198        MarkdownRenderer.render(&diff, &mut buf).unwrap();
199        let out = String::from_utf8(buf).unwrap();
200        assert!(out.contains("### SBOM Diff Summary"));
201        assert!(out.contains("<details>"));
202    }
203
204    #[test]
205    fn test_json_renderer() {
206        let diff = mock_diff();
207        let mut buf = Vec::new();
208        JsonRenderer.render(&diff, &mut buf).unwrap();
209        let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
210    }
211}