1use crate::{Diff, FieldChange};
10use std::io::Write;
11
12pub trait Renderer {
14 fn render<W: Write>(&self, diff: &Diff, writer: &mut W) -> anyhow::Result<()>;
16}
17
18pub 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
79pub 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**: `{}` → `{}`", old, new)?;
140 }
141 FieldChange::License(old, new) => {
142 writeln!(writer, "- **License**: `{:?}` → `{:?}`", old, new)?;
143 }
144 FieldChange::Supplier(old, new) => {
145 writeln!(writer, "- **Supplier**: `{:?}` → `{:?}`", old, new)?;
146 }
147 FieldChange::Purl(old, new) => {
148 writeln!(writer, "- **Purl**: `{:?}` → `{:?}`", 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
163pub 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}