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**: `{}` → `{}`", old, new)?;
126 }
127 FieldChange::License(old, new) => {
128 writeln!(writer, "- **License**: `{:?}` → `{:?}`", old, new)?;
129 }
130 FieldChange::Supplier(old, new) => {
131 writeln!(writer, "- **Supplier**: `{:?}` → `{:?}`", old, new)?;
132 }
133 FieldChange::Purl(old, new) => {
134 writeln!(writer, "- **Purl**: `{:?}` → `{:?}`", 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}