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