1use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff};
10use sbom_model::Component;
11use serde::Serialize;
12use std::collections::{BTreeMap, BTreeSet};
13use std::io::Write;
14
15#[derive(Debug, Clone, Default)]
17pub struct RenderOptions {
18 pub group_by_ecosystem: bool,
20 pub show_warnings: bool,
22 pub old_warnings: Vec<String>,
24 pub new_warnings: Vec<String>,
26}
27
28impl RenderOptions {
29 pub fn has_warnings(&self) -> bool {
31 self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
32 }
33
34 pub fn warning_count(&self) -> usize {
36 self.old_warnings.len() + self.new_warnings.len()
37 }
38}
39
40fn format_option(opt: &Option<String>) -> &str {
41 opt.as_deref().unwrap_or("<none>")
42}
43
44fn format_set(set: &BTreeSet<String>) -> String {
45 if set.is_empty() {
46 "<none>".to_string()
47 } else {
48 set.iter().cloned().collect::<Vec<_>>().join(", ")
49 }
50}
51
52pub trait Renderer {
54 fn render<W: Write>(
56 &self,
57 diff: &Diff,
58 opts: &RenderOptions,
59 writer: &mut W,
60 ) -> anyhow::Result<()>;
61}
62
63trait FieldChangeFormatter {
66 fn field_change<W: Write>(
67 &self,
68 w: &mut W,
69 name: &str,
70 old: &str,
71 new: &str,
72 ) -> std::io::Result<()>;
73 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
74 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
75 fn hash_changed<W: Write>(
76 &self,
77 w: &mut W,
78 algo: &str,
79 old: &str,
80 new: &str,
81 ) -> std::io::Result<()>;
82 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
83 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
84}
85
86fn write_field_changes<F: FieldChangeFormatter, W: Write>(
87 fmt: &F,
88 writer: &mut W,
89 changes: &[FieldChange],
90) -> std::io::Result<()> {
91 for change in changes {
92 match change {
93 FieldChange::Version(old, new) => {
94 fmt.field_change(writer, "Version", old, new)?;
95 }
96 FieldChange::License(old, new) => {
97 fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
98 }
99 FieldChange::Supplier(old, new) => {
100 fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
101 }
102 FieldChange::Purl(old, new) => {
103 fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
104 }
105 FieldChange::Description(old, new) => {
106 fmt.field_change(
107 writer,
108 "Description",
109 format_option(old),
110 format_option(new),
111 )?;
112 }
113 FieldChange::Hashes(old, new) => {
114 fmt.hash_header(writer)?;
115 for (algo, digest) in old {
116 if !new.contains_key(algo) {
117 fmt.hash_removed(writer, algo, digest)?;
118 } else if new[algo] != *digest {
119 fmt.hash_changed(writer, algo, digest, &new[algo])?;
120 }
121 }
122 for (algo, digest) in new {
123 if !old.contains_key(algo) {
124 fmt.hash_added(writer, algo, digest)?;
125 }
126 }
127 }
128 }
129 }
130 Ok(())
131}
132
133fn write_changed<F: FieldChangeFormatter, W: Write>(
134 fmt: &F,
135 writer: &mut W,
136 changes: &[ComponentChange],
137) -> std::io::Result<()> {
138 for c in changes {
139 fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
140 write_field_changes(fmt, writer, &c.changes)?;
141 }
142 Ok(())
143}
144
145fn write_text_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
148 for c in components {
149 writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
150 }
151 Ok(())
152}
153
154pub struct TextRenderer;
156
157impl FieldChangeFormatter for TextRenderer {
158 fn field_change<W: Write>(
159 &self,
160 w: &mut W,
161 name: &str,
162 old: &str,
163 new: &str,
164 ) -> std::io::Result<()> {
165 writeln!(w, " {}: {} -> {}", name, old, new)
166 }
167
168 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
169 writeln!(w, " Hashes:")
170 }
171
172 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
173 writeln!(w, " - {}: {}", algo, digest)
174 }
175
176 fn hash_changed<W: Write>(
177 &self,
178 w: &mut W,
179 algo: &str,
180 old: &str,
181 new: &str,
182 ) -> std::io::Result<()> {
183 writeln!(w, " ~ {}: {} -> {}", algo, old, new)
184 }
185
186 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
187 writeln!(w, " + {}: {}", algo, digest)
188 }
189
190 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
191 writeln!(w, "{}", id)
192 }
193}
194
195impl Renderer for TextRenderer {
196 fn render<W: Write>(
197 &self,
198 diff: &Diff,
199 opts: &RenderOptions,
200 writer: &mut W,
201 ) -> anyhow::Result<()> {
202 if opts.has_warnings() {
203 writeln!(writer, "[!] Warnings")?;
204 writeln!(writer, "------------")?;
205 for w in &opts.old_warnings {
206 writeln!(writer, "[old] {}", w)?;
207 }
208 for w in &opts.new_warnings {
209 writeln!(writer, "[new] {}", w)?;
210 }
211 writeln!(writer)?;
212 }
213
214 writeln!(writer, "Diff Summary")?;
215 writeln!(writer, "============")?;
216 writeln!(writer, "Old total: {} components", diff.old_total)?;
217 writeln!(writer, "New total: {} components", diff.new_total)?;
218 writeln!(writer, "Unchanged: {}", diff.unchanged)?;
219 writeln!(writer, "Added: {}", diff.added.len())?;
220 writeln!(writer, "Removed: {}", diff.removed.len())?;
221 writeln!(writer, "Changed: {}", diff.changed.len())?;
222 writeln!(writer)?;
223
224 if opts.group_by_ecosystem {
225 let grouped = diff.group_by_ecosystem();
226 let breakdown = grouped.ecosystem_breakdown();
227
228 writeln!(writer, "By Ecosystem")?;
229 writeln!(writer, "------------")?;
230 for (eco, counts) in &breakdown {
231 writeln!(
232 writer,
233 "{}: {} added, {} removed, {} changed",
234 eco, counts.added, counts.removed, counts.changed
235 )?;
236 }
237 writeln!(writer)?;
238
239 for (eco, eco_diff) in &grouped.by_ecosystem {
240 writeln!(writer, "[{}]", eco)?;
241 writeln!(writer)?;
242 if !eco_diff.added.is_empty() {
243 writeln!(writer, "[+] Added")?;
244 writeln!(writer, "---------")?;
245 write_text_added(writer, &eco_diff.added)?;
246 writeln!(writer)?;
247 }
248 if !eco_diff.removed.is_empty() {
249 writeln!(writer, "[-] Removed")?;
250 writeln!(writer, "-----------")?;
251 write_text_added(writer, &eco_diff.removed)?;
252 writeln!(writer)?;
253 }
254 if !eco_diff.changed.is_empty() {
255 writeln!(writer, "[~] Changed")?;
256 writeln!(writer, "-----------")?;
257 write_changed(self, writer, &eco_diff.changed)?;
258 writeln!(writer)?;
259 }
260 }
261 } else {
262 if !diff.added.is_empty() {
263 writeln!(writer, "[+] Added")?;
264 writeln!(writer, "---------")?;
265 write_text_added(writer, &diff.added)?;
266 writeln!(writer)?;
267 }
268
269 if !diff.removed.is_empty() {
270 writeln!(writer, "[-] Removed")?;
271 writeln!(writer, "-----------")?;
272 write_text_added(writer, &diff.removed)?;
273 writeln!(writer)?;
274 }
275
276 if !diff.changed.is_empty() {
277 writeln!(writer, "[~] Changed")?;
278 writeln!(writer, "-----------")?;
279 write_changed(self, writer, &diff.changed)?;
280 writeln!(writer)?;
281 }
282 }
283
284 if !diff.edge_diffs.is_empty() {
285 writeln!(writer, "[~] Edge Changes")?;
286 writeln!(writer, "----------------")?;
287 for edge in &diff.edge_diffs {
288 writeln!(writer, "{}", diff.display_name(&edge.parent))?;
289 for removed in &edge.removed {
290 writeln!(writer, " - {}", diff.display_name(removed))?;
291 }
292 for added in &edge.added {
293 writeln!(writer, " + {}", diff.display_name(added))?;
294 }
295 }
296 }
297
298 Ok(())
299 }
300}
301
302fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
305 for c in components {
306 writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
307 }
308 Ok(())
309}
310
311pub struct MarkdownRenderer;
315
316impl FieldChangeFormatter for MarkdownRenderer {
317 fn field_change<W: Write>(
318 &self,
319 w: &mut W,
320 name: &str,
321 old: &str,
322 new: &str,
323 ) -> std::io::Result<()> {
324 writeln!(w, "- **{}**: `{}` → `{}`", name, old, new)
325 }
326
327 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
328 writeln!(w, "- **Hashes**:")
329 }
330
331 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
332 writeln!(w, " - `{}`: removed `{}`", algo, digest)
333 }
334
335 fn hash_changed<W: Write>(
336 &self,
337 w: &mut W,
338 algo: &str,
339 old: &str,
340 new: &str,
341 ) -> std::io::Result<()> {
342 writeln!(w, " - `{}`: `{}` → `{}`", algo, old, new)
343 }
344
345 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
346 writeln!(w, " - `{}`: added `{}`", algo, digest)
347 }
348
349 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
350 writeln!(w, "#### `{}`", id)
351 }
352}
353
354impl Renderer for MarkdownRenderer {
355 fn render<W: Write>(
356 &self,
357 diff: &Diff,
358 opts: &RenderOptions,
359 writer: &mut W,
360 ) -> anyhow::Result<()> {
361 if opts.has_warnings() {
362 writeln!(
363 writer,
364 "<details><summary><b>Warnings ({})</b></summary>",
365 opts.warning_count()
366 )?;
367 writeln!(writer)?;
368 for w in &opts.old_warnings {
369 writeln!(writer, "- **old:** {}", w)?;
370 }
371 for w in &opts.new_warnings {
372 writeln!(writer, "- **new:** {}", w)?;
373 }
374 writeln!(writer, "</details>")?;
375 writeln!(writer)?;
376 }
377
378 writeln!(writer, "### SBOM Diff Summary")?;
379 writeln!(writer)?;
380 writeln!(writer, "| Metric | Count |")?;
381 writeln!(writer, "| --- | --- |")?;
382 writeln!(writer, "| Old total | {} |", diff.old_total)?;
383 writeln!(writer, "| New total | {} |", diff.new_total)?;
384 writeln!(writer, "| Unchanged | {} |", diff.unchanged)?;
385 writeln!(writer, "| Added | {} |", diff.added.len())?;
386 writeln!(writer, "| Removed | {} |", diff.removed.len())?;
387 writeln!(writer, "| Changed | {} |", diff.changed.len())?;
388 writeln!(writer)?;
389
390 if opts.group_by_ecosystem {
391 let grouped = diff.group_by_ecosystem();
392 let breakdown = grouped.ecosystem_breakdown();
393
394 writeln!(writer, "#### By Ecosystem")?;
395 writeln!(writer)?;
396 writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
397 writeln!(writer, "| --- | --- | --- | --- |")?;
398 for (eco, counts) in &breakdown {
399 writeln!(
400 writer,
401 "| {} | {} | {} | {} |",
402 eco, counts.added, counts.removed, counts.changed
403 )?;
404 }
405 writeln!(writer)?;
406
407 for (eco, eco_diff) in &grouped.by_ecosystem {
408 writeln!(writer, "#### {}", eco)?;
409 writeln!(writer)?;
410 if !eco_diff.added.is_empty() {
411 writeln!(
412 writer,
413 "<details><summary><b>Added ({})</b></summary>",
414 eco_diff.added.len()
415 )?;
416 writeln!(writer)?;
417 write_md_added(writer, &eco_diff.added)?;
418 writeln!(writer, "</details>")?;
419 writeln!(writer)?;
420 }
421 if !eco_diff.removed.is_empty() {
422 writeln!(
423 writer,
424 "<details><summary><b>Removed ({})</b></summary>",
425 eco_diff.removed.len()
426 )?;
427 writeln!(writer)?;
428 write_md_added(writer, &eco_diff.removed)?;
429 writeln!(writer, "</details>")?;
430 writeln!(writer)?;
431 }
432 if !eco_diff.changed.is_empty() {
433 writeln!(
434 writer,
435 "<details><summary><b>Changed ({})</b></summary>",
436 eco_diff.changed.len()
437 )?;
438 writeln!(writer)?;
439 write_changed(self, writer, &eco_diff.changed)?;
440 writeln!(writer, "</details>")?;
441 writeln!(writer)?;
442 }
443 }
444 } else {
445 if !diff.added.is_empty() {
446 writeln!(
447 writer,
448 "<details><summary><b>Added ({})</b></summary>",
449 diff.added.len()
450 )?;
451 writeln!(writer)?;
452 write_md_added(writer, &diff.added)?;
453 writeln!(writer, "</details>")?;
454 writeln!(writer)?;
455 }
456
457 if !diff.removed.is_empty() {
458 writeln!(
459 writer,
460 "<details><summary><b>Removed ({})</b></summary>",
461 diff.removed.len()
462 )?;
463 writeln!(writer)?;
464 write_md_added(writer, &diff.removed)?;
465 writeln!(writer, "</details>")?;
466 writeln!(writer)?;
467 }
468
469 if !diff.changed.is_empty() {
470 writeln!(
471 writer,
472 "<details><summary><b>Changed ({})</b></summary>",
473 diff.changed.len()
474 )?;
475 writeln!(writer)?;
476 write_changed(self, writer, &diff.changed)?;
477 writeln!(writer, "</details>")?;
478 writeln!(writer)?;
479 }
480 }
481
482 if !diff.edge_diffs.is_empty() {
483 writeln!(
484 writer,
485 "<details><summary><b>Edge Changes ({})</b></summary>",
486 diff.edge_diffs.len()
487 )?;
488 writeln!(writer)?;
489 for edge in &diff.edge_diffs {
490 writeln!(writer, "#### `{}`", diff.display_name(&edge.parent))?;
491 if !edge.removed.is_empty() {
492 writeln!(writer, "**Removed dependencies:**")?;
493 for removed in &edge.removed {
494 writeln!(writer, "- `{}`", diff.display_name(removed))?;
495 }
496 }
497 if !edge.added.is_empty() {
498 writeln!(writer, "**Added dependencies:**")?;
499 for added in &edge.added {
500 writeln!(writer, "- `{}`", diff.display_name(added))?;
501 }
502 }
503 writeln!(writer)?;
504 }
505 writeln!(writer, "</details>")?;
506 }
507
508 Ok(())
509 }
510}
511
512pub struct JsonRenderer;
519
520#[derive(Serialize)]
522struct JsonOutput<'a> {
523 #[serde(flatten)]
524 diff: &'a Diff,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 ecosystem_breakdown: Option<BTreeMap<String, EcosystemCounts>>,
527 #[serde(skip_serializing_if = "Option::is_none")]
528 by_ecosystem: Option<&'a GroupedDiff>,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 warnings: Option<JsonWarnings<'a>>,
531}
532
533#[derive(Serialize)]
534struct JsonWarnings<'a> {
535 old: &'a Vec<String>,
536 new: &'a Vec<String>,
537}
538
539impl Renderer for JsonRenderer {
540 fn render<W: Write>(
541 &self,
542 diff: &Diff,
543 opts: &RenderOptions,
544 writer: &mut W,
545 ) -> anyhow::Result<()> {
546 let warnings = if opts.has_warnings() {
547 Some(JsonWarnings {
548 old: &opts.old_warnings,
549 new: &opts.new_warnings,
550 })
551 } else {
552 None
553 };
554
555 if opts.group_by_ecosystem {
556 let grouped = diff.group_by_ecosystem();
557 let output = JsonOutput {
558 diff,
559 ecosystem_breakdown: Some(grouped.ecosystem_breakdown()),
560 by_ecosystem: Some(&grouped),
561 warnings,
562 };
563 serde_json::to_writer_pretty(writer, &output)?;
564 } else {
565 let output = JsonOutput {
566 diff,
567 ecosystem_breakdown: None,
568 by_ecosystem: None,
569 warnings,
570 };
571 serde_json::to_writer_pretty(writer, &output)?;
572 }
573 Ok(())
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::{ComponentChange, Diff, FieldChange};
581 use sbom_model::Component;
582 use std::collections::BTreeMap;
583
584 fn mock_diff() -> Diff {
585 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
586 let mut c2 = c1.clone();
587 c2.version = Some("1.1".into());
588
589 Diff {
590 added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
591 removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
592 changed: vec![ComponentChange {
593 id: c2.id.clone(),
594 old: c1,
595 new: c2,
596 changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
597 }],
598 edge_diffs: vec![],
599 ..Diff::default()
600 }
601 }
602
603 fn mock_diff_all_field_changes() -> Diff {
604 use sbom_model::ComponentId;
605 use std::collections::BTreeSet;
606
607 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
608 let mut c2 = c1.clone();
609 c2.version = Some("1.1".into());
610
611 Diff {
612 added: vec![],
613 removed: vec![],
614 changed: vec![ComponentChange {
615 id: c2.id.clone(),
616 old: c1,
617 new: c2,
618 changes: vec![
619 FieldChange::Version("1.0".into(), "1.1".into()),
620 FieldChange::License(
621 BTreeSet::from(["MIT".into()]),
622 BTreeSet::from(["Apache-2.0".into()]),
623 ),
624 FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
625 FieldChange::Purl(
626 Some("pkg:npm/pkg-a@1.0".into()),
627 Some("pkg:npm/pkg-a@1.1".into()),
628 ),
629 FieldChange::Description(
630 Some("Old description".into()),
631 Some("New description".into()),
632 ),
633 FieldChange::Hashes(
634 BTreeMap::from([("sha256".into(), "aaa".into())]),
635 BTreeMap::from([("sha256".into(), "bbb".into())]),
636 ),
637 ],
638 }],
639 edge_diffs: vec![crate::EdgeDiff {
640 parent: ComponentId::new(None, &[("name", "parent")]),
641 added: BTreeSet::from([ComponentId::new(None, &[("name", "child-b")])]),
642 removed: BTreeSet::from([ComponentId::new(None, &[("name", "child-a")])]),
643 }],
644 ..Diff::default()
645 }
646 }
647
648 fn mock_diff_empty() -> Diff {
649 Diff {
650 added: vec![],
651 removed: vec![],
652 changed: vec![],
653 edge_diffs: vec![],
654 ..Diff::default()
655 }
656 }
657
658 #[test]
659 fn test_text_renderer() {
660 let diff = mock_diff();
661 let mut buf = Vec::new();
662 TextRenderer
663 .render(&diff, &RenderOptions::default(), &mut buf)
664 .unwrap();
665 let out = String::from_utf8(buf).unwrap();
666 assert!(out.contains("Diff Summary"));
667 assert!(out.contains("[+] Added"));
668 assert!(out.contains("[-] Removed"));
669 assert!(out.contains("[~] Changed"));
670 }
671
672 #[test]
673 fn test_text_renderer_all_field_changes() {
674 let diff = mock_diff_all_field_changes();
675 let mut buf = Vec::new();
676 TextRenderer
677 .render(&diff, &RenderOptions::default(), &mut buf)
678 .unwrap();
679 let out = String::from_utf8(buf).unwrap();
680
681 assert!(out.contains("Version: 1.0 -> 1.1"));
682 assert!(out.contains("License:"));
683 assert!(out.contains("MIT"));
684 assert!(out.contains("Apache-2.0"));
685 assert!(out.contains("Supplier:"));
686 assert!(out.contains("Old Corp"));
687 assert!(out.contains("New Corp"));
688 assert!(out.contains("Purl:"));
689 assert!(out.contains("Description:"));
690 assert!(out.contains("Old description"));
691 assert!(out.contains("New description"));
692 assert!(out.contains("Hashes:"));
693 assert!(out.contains("~ sha256: aaa -> bbb"));
694 assert!(out.contains("[~] Edge Changes"));
695 }
696
697 #[test]
698 fn test_text_renderer_empty_diff() {
699 let diff = mock_diff_empty();
700 let mut buf = Vec::new();
701 TextRenderer
702 .render(&diff, &RenderOptions::default(), &mut buf)
703 .unwrap();
704 let out = String::from_utf8(buf).unwrap();
705
706 assert!(out.contains("Old total: 0 components"));
707 assert!(out.contains("New total: 0 components"));
708 assert!(out.contains("Unchanged: 0"));
709 assert!(out.contains("Added: 0"));
710 assert!(out.contains("Removed: 0"));
711 assert!(out.contains("Changed: 0"));
712 assert!(!out.contains("[+] Added"));
713 assert!(!out.contains("[-] Removed"));
714 assert!(!out.contains("[~] Changed"));
715 }
716
717 #[test]
718 fn test_markdown_renderer() {
719 let diff = mock_diff();
720 let mut buf = Vec::new();
721 MarkdownRenderer
722 .render(&diff, &RenderOptions::default(), &mut buf)
723 .unwrap();
724 let out = String::from_utf8(buf).unwrap();
725 assert!(out.contains("### SBOM Diff Summary"));
726 assert!(out.contains("<details>"));
727 }
728
729 #[test]
730 fn test_markdown_renderer_all_field_changes() {
731 let diff = mock_diff_all_field_changes();
732 let mut buf = Vec::new();
733 MarkdownRenderer
734 .render(&diff, &RenderOptions::default(), &mut buf)
735 .unwrap();
736 let out = String::from_utf8(buf).unwrap();
737
738 assert!(out.contains("**Version**"));
739 assert!(out.contains("**License**"));
740 assert!(out.contains("**Supplier**"));
741 assert!(out.contains("**Purl**"));
742 assert!(out.contains("**Description**"));
743 assert!(out.contains("**Hashes**:"));
744 assert!(out.contains("`sha256`: `aaa` → `bbb`"));
745 assert!(out.contains("Edge Changes"));
746 assert!(out.contains("**Removed dependencies:**"));
747 assert!(out.contains("**Added dependencies:**"));
748 }
749
750 #[test]
751 fn test_markdown_renderer_empty_diff() {
752 let diff = mock_diff_empty();
753 let mut buf = Vec::new();
754 MarkdownRenderer
755 .render(&diff, &RenderOptions::default(), &mut buf)
756 .unwrap();
757 let out = String::from_utf8(buf).unwrap();
758
759 assert!(out.contains("| Added | 0 |"));
760 assert!(!out.contains("<details>"));
761 }
762
763 #[test]
764 fn test_json_renderer() {
765 let diff = mock_diff();
766 let mut buf = Vec::new();
767 JsonRenderer
768 .render(&diff, &RenderOptions::default(), &mut buf)
769 .unwrap();
770 let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
771 }
772
773 #[test]
774 fn test_json_renderer_all_field_changes() {
775 let diff = mock_diff_all_field_changes();
776 let mut buf = Vec::new();
777 JsonRenderer
778 .render(&diff, &RenderOptions::default(), &mut buf)
779 .unwrap();
780 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
781
782 assert_eq!(val["changed"].as_array().unwrap().len(), 1);
783 assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 6);
784 assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
785 }
786
787 #[test]
788 fn test_json_renderer_roundtrip() {
789 let diff = mock_diff_all_field_changes();
790 let mut buf = Vec::new();
791 JsonRenderer
792 .render(&diff, &RenderOptions::default(), &mut buf)
793 .unwrap();
794
795 let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
796 assert_eq!(deserialized.changed.len(), diff.changed.len());
797 assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
798 assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
799 }
800
801 fn mock_diff_with_ecosystems() -> Diff {
802 let mut added_npm = Component::new("express".into(), Some("4.18.0".into()));
803 added_npm.ecosystem = Some("npm".into());
804 let mut added_cargo = Component::new("serde".into(), Some("1.0.0".into()));
805 added_cargo.ecosystem = Some("cargo".into());
806
807 let mut removed = Component::new("lodash".into(), Some("4.17.21".into()));
808 removed.ecosystem = Some("npm".into());
809
810 let mut old = Component::new("react".into(), Some("17.0.0".into()));
811 old.ecosystem = Some("npm".into());
812 let mut new = old.clone();
813 new.version = Some("18.0.0".into());
814
815 Diff {
816 added: vec![added_npm, added_cargo],
817 removed: vec![removed],
818 changed: vec![ComponentChange {
819 id: new.id.clone(),
820 old,
821 new,
822 changes: vec![FieldChange::Version("17.0.0".into(), "18.0.0".into())],
823 }],
824 edge_diffs: vec![],
825 ..Diff::default()
826 }
827 }
828
829 #[test]
830 fn test_text_renderer_group_by_ecosystem() {
831 let diff = mock_diff_with_ecosystems();
832 let opts = RenderOptions {
833 group_by_ecosystem: true,
834 ..Default::default()
835 };
836 let mut buf = Vec::new();
837 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
838 let out = String::from_utf8(buf).unwrap();
839
840 assert!(out.contains("By Ecosystem"));
841 assert!(out.contains("cargo: 1 added, 0 removed, 0 changed"));
842 assert!(out.contains("npm: 1 added, 1 removed, 1 changed"));
843 }
844
845 #[test]
846 fn test_text_renderer_no_ecosystem_by_default() {
847 let diff = mock_diff_with_ecosystems();
848 let mut buf = Vec::new();
849 TextRenderer
850 .render(&diff, &RenderOptions::default(), &mut buf)
851 .unwrap();
852 let out = String::from_utf8(buf).unwrap();
853
854 assert!(!out.contains("By Ecosystem"));
855 }
856
857 #[test]
858 fn test_markdown_renderer_group_by_ecosystem() {
859 let diff = mock_diff_with_ecosystems();
860 let opts = RenderOptions {
861 group_by_ecosystem: true,
862 ..Default::default()
863 };
864 let mut buf = Vec::new();
865 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
866 let out = String::from_utf8(buf).unwrap();
867
868 assert!(out.contains("#### By Ecosystem"));
869 assert!(out.contains("| Ecosystem | Added | Removed | Changed |"));
870 assert!(out.contains("| cargo | 1 | 0 | 0 |"));
871 assert!(out.contains("| npm | 1 | 1 | 1 |"));
872 }
873
874 #[test]
875 fn test_json_renderer_group_by_ecosystem() {
876 let diff = mock_diff_with_ecosystems();
877 let opts = RenderOptions {
878 group_by_ecosystem: true,
879 ..Default::default()
880 };
881 let mut buf = Vec::new();
882 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
883 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
884
885 let breakdown = &val["ecosystem_breakdown"];
886 assert!(breakdown.is_object());
887 assert_eq!(breakdown["npm"]["added"], 1);
888 assert_eq!(breakdown["npm"]["removed"], 1);
889 assert_eq!(breakdown["npm"]["changed"], 1);
890 assert_eq!(breakdown["cargo"]["added"], 1);
891 assert_eq!(breakdown["cargo"]["removed"], 0);
892 }
893
894 #[test]
895 fn test_json_renderer_no_ecosystem_by_default() {
896 let diff = mock_diff_with_ecosystems();
897 let mut buf = Vec::new();
898 JsonRenderer
899 .render(&diff, &RenderOptions::default(), &mut buf)
900 .unwrap();
901 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
902
903 assert!(val.get("ecosystem_breakdown").is_none());
904 }
905
906 fn opts_with_warnings() -> RenderOptions {
907 RenderOptions {
908 show_warnings: true,
909 old_warnings: vec!["SPDX: orphaned ref 'SPDXRef-foo'".into()],
910 new_warnings: vec!["CycloneDX: unknown bom-ref 'bar'".into()],
911 ..Default::default()
912 }
913 }
914
915 #[test]
916 fn test_text_renderer_shows_warnings() {
917 let diff = mock_diff();
918 let opts = opts_with_warnings();
919 let mut buf = Vec::new();
920 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
921 let out = String::from_utf8(buf).unwrap();
922
923 assert!(out.contains("[!] Warnings"));
924 assert!(out.contains("[old] SPDX: orphaned ref 'SPDXRef-foo'"));
925 assert!(out.contains("[new] CycloneDX: unknown bom-ref 'bar'"));
926 }
927
928 #[test]
929 fn test_text_renderer_hides_warnings_by_default() {
930 let diff = mock_diff();
931 let mut buf = Vec::new();
932 TextRenderer
933 .render(&diff, &RenderOptions::default(), &mut buf)
934 .unwrap();
935 let out = String::from_utf8(buf).unwrap();
936
937 assert!(!out.contains("[!] Warnings"));
938 }
939
940 #[test]
941 fn test_markdown_renderer_shows_warnings() {
942 let diff = mock_diff();
943 let opts = opts_with_warnings();
944 let mut buf = Vec::new();
945 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
946 let out = String::from_utf8(buf).unwrap();
947
948 assert!(out.contains("<details><summary><b>Warnings (2)</b></summary>"));
949 assert!(out.contains("- **old:** SPDX: orphaned ref 'SPDXRef-foo'"));
950 assert!(out.contains("- **new:** CycloneDX: unknown bom-ref 'bar'"));
951 }
952
953 #[test]
954 fn test_markdown_renderer_hides_warnings_by_default() {
955 let diff = mock_diff();
956 let mut buf = Vec::new();
957 MarkdownRenderer
958 .render(&diff, &RenderOptions::default(), &mut buf)
959 .unwrap();
960 let out = String::from_utf8(buf).unwrap();
961
962 assert!(!out.contains("Warnings"));
963 }
964
965 #[test]
966 fn test_json_renderer_shows_warnings() {
967 let diff = mock_diff();
968 let opts = opts_with_warnings();
969 let mut buf = Vec::new();
970 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
971 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
972
973 let warnings = &val["warnings"];
974 let old = warnings["old"].as_array().unwrap();
975 let new = warnings["new"].as_array().unwrap();
976 assert_eq!(old.len(), 1);
977 assert_eq!(new.len(), 1);
978 assert_eq!(old[0], "SPDX: orphaned ref 'SPDXRef-foo'");
979 assert_eq!(new[0], "CycloneDX: unknown bom-ref 'bar'");
980 }
981
982 #[test]
983 fn test_json_renderer_hides_warnings_by_default() {
984 let diff = mock_diff();
985 let mut buf = Vec::new();
986 JsonRenderer
987 .render(&diff, &RenderOptions::default(), &mut buf)
988 .unwrap();
989 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
990
991 assert!(val.get("warnings").is_none());
992 }
993
994 #[test]
995 fn test_empty_warnings_not_shown() {
996 let diff = mock_diff();
997 let opts = RenderOptions {
998 show_warnings: true,
999 ..Default::default()
1000 };
1001
1002 let mut buf = Vec::new();
1003 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1004 let out = String::from_utf8(buf).unwrap();
1005 assert!(!out.contains("[!] Warnings"));
1006
1007 let mut buf = Vec::new();
1008 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1009 let out = String::from_utf8(buf).unwrap();
1010 assert!(!out.contains("Warnings"));
1011
1012 let mut buf = Vec::new();
1013 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1014 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1015 assert!(val.get("warnings").is_none());
1016 }
1017
1018 fn mock_diff_with_hash_edge_diffs() -> Diff {
1019 use sbom_model::ComponentId;
1020 use std::collections::BTreeSet;
1021
1022 let parent_id = ComponentId::new(None, &[("name", "parent")]);
1023 let child_a_id = ComponentId::new(None, &[("name", "child-a")]);
1024 let child_b_id = ComponentId::new(None, &[("name", "child-b")]);
1025
1026 let mut names = BTreeMap::new();
1027 names.insert(parent_id.clone(), "my-app@1.0".to_string());
1028 names.insert(child_a_id.clone(), "old-dep@0.1".to_string());
1029 names.insert(child_b_id.clone(), "new-dep@0.2".to_string());
1030
1031 Diff {
1032 edge_diffs: vec![crate::EdgeDiff {
1033 parent: parent_id,
1034 added: BTreeSet::from([child_b_id]),
1035 removed: BTreeSet::from([child_a_id]),
1036 }],
1037 old_total: 10,
1038 new_total: 12,
1039 unchanged: 5,
1040 component_names: names,
1041 ..Diff::default()
1042 }
1043 }
1044
1045 #[test]
1046 fn test_text_renderer_resolves_edge_diff_names() {
1047 let diff = mock_diff_with_hash_edge_diffs();
1048 let mut buf = Vec::new();
1049 TextRenderer
1050 .render(&diff, &RenderOptions::default(), &mut buf)
1051 .unwrap();
1052 let out = String::from_utf8(buf).unwrap();
1053
1054 assert!(out.contains("my-app@1.0"));
1055 assert!(out.contains("- old-dep@0.1"));
1056 assert!(out.contains("+ new-dep@0.2"));
1057 assert!(!out.contains("h:"));
1059 }
1060
1061 #[test]
1062 fn test_text_renderer_shows_totals() {
1063 let diff = mock_diff_with_hash_edge_diffs();
1064 let mut buf = Vec::new();
1065 TextRenderer
1066 .render(&diff, &RenderOptions::default(), &mut buf)
1067 .unwrap();
1068 let out = String::from_utf8(buf).unwrap();
1069
1070 assert!(out.contains("Old total: 10 components"));
1071 assert!(out.contains("New total: 12 components"));
1072 assert!(out.contains("Unchanged: 5"));
1073 }
1074
1075 #[test]
1076 fn test_markdown_renderer_resolves_edge_diff_names() {
1077 let diff = mock_diff_with_hash_edge_diffs();
1078 let mut buf = Vec::new();
1079 MarkdownRenderer
1080 .render(&diff, &RenderOptions::default(), &mut buf)
1081 .unwrap();
1082 let out = String::from_utf8(buf).unwrap();
1083
1084 assert!(out.contains("`my-app@1.0`"));
1085 assert!(out.contains("`old-dep@0.1`"));
1086 assert!(out.contains("`new-dep@0.2`"));
1087 assert!(!out.contains("h:"));
1088 }
1089
1090 #[test]
1091 fn test_markdown_renderer_shows_totals() {
1092 let diff = mock_diff_with_hash_edge_diffs();
1093 let mut buf = Vec::new();
1094 MarkdownRenderer
1095 .render(&diff, &RenderOptions::default(), &mut buf)
1096 .unwrap();
1097 let out = String::from_utf8(buf).unwrap();
1098
1099 assert!(out.contains("| Old total | 10 |"));
1100 assert!(out.contains("| New total | 12 |"));
1101 assert!(out.contains("| Unchanged | 5 |"));
1102 }
1103
1104 #[test]
1105 fn test_json_renderer_includes_totals() {
1106 let diff = mock_diff_with_hash_edge_diffs();
1107 let mut buf = Vec::new();
1108 JsonRenderer
1109 .render(&diff, &RenderOptions::default(), &mut buf)
1110 .unwrap();
1111 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1112
1113 assert_eq!(val["old_total"], 10);
1114 assert_eq!(val["new_total"], 12);
1115 assert_eq!(val["unchanged"], 5);
1116 }
1117
1118 #[test]
1119 fn test_json_renderer_includes_component_names() {
1120 let diff = mock_diff_with_hash_edge_diffs();
1121 let mut buf = Vec::new();
1122 JsonRenderer
1123 .render(&diff, &RenderOptions::default(), &mut buf)
1124 .unwrap();
1125 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1126
1127 let names = &val["component_names"];
1128 assert!(names.is_object());
1129 assert!(names
1130 .as_object()
1131 .unwrap()
1132 .values()
1133 .any(|v| v == "my-app@1.0"));
1134 }
1135
1136 #[test]
1137 fn test_json_renderer_omits_empty_component_names() {
1138 let diff = mock_diff();
1139 let mut buf = Vec::new();
1140 JsonRenderer
1141 .render(&diff, &RenderOptions::default(), &mut buf)
1142 .unwrap();
1143 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1144
1145 assert!(val.get("component_names").is_none());
1146 }
1147}