1use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff};
10use sbom_model::{Component, DependencyKind};
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 kind_suffix(kind: &DependencyKind) -> &'static str {
43 match kind {
44 DependencyKind::Runtime => "",
45 DependencyKind::Dev => " (dev)",
46 DependencyKind::Build => " (build)",
47 DependencyKind::Test => " (test)",
48 DependencyKind::Optional => " (optional)",
49 DependencyKind::Provided => " (provided)",
50 }
51}
52
53fn format_option(opt: &Option<String>) -> &str {
54 opt.as_deref().unwrap_or("<none>")
55}
56
57fn format_set(set: &BTreeSet<String>) -> String {
58 if set.is_empty() {
59 "<none>".to_string()
60 } else {
61 set.iter().cloned().collect::<Vec<_>>().join(", ")
62 }
63}
64
65pub trait Renderer {
67 fn render<W: Write>(
69 &self,
70 diff: &Diff,
71 opts: &RenderOptions,
72 writer: &mut W,
73 ) -> anyhow::Result<()>;
74}
75
76pub trait SummaryRenderer {
80 fn render_summary<W: Write>(
82 &self,
83 diff: &Diff,
84 opts: &RenderOptions,
85 writer: &mut W,
86 ) -> anyhow::Result<()>;
87}
88
89trait FieldChangeFormatter {
92 fn field_change<W: Write>(
93 &self,
94 w: &mut W,
95 name: &str,
96 old: &str,
97 new: &str,
98 ) -> std::io::Result<()>;
99 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
100 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
101 fn hash_changed<W: Write>(
102 &self,
103 w: &mut W,
104 algo: &str,
105 old: &str,
106 new: &str,
107 ) -> std::io::Result<()>;
108 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
109 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
110}
111
112fn write_field_changes<F: FieldChangeFormatter, W: Write>(
113 fmt: &F,
114 writer: &mut W,
115 changes: &[FieldChange],
116) -> std::io::Result<()> {
117 for change in changes {
118 match change {
119 FieldChange::Version(old, new) => {
120 fmt.field_change(writer, "Version", old, new)?;
121 }
122 FieldChange::License(old, new) => {
123 fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
124 }
125 FieldChange::Supplier(old, new) => {
126 fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
127 }
128 FieldChange::Purl(old, new) => {
129 fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
130 }
131 FieldChange::Description(old, new) => {
132 fmt.field_change(
133 writer,
134 "Description",
135 format_option(old),
136 format_option(new),
137 )?;
138 }
139 FieldChange::Hashes(old, new) => {
140 fmt.hash_header(writer)?;
141 for (algo, digest) in old {
142 if !new.contains_key(algo) {
143 fmt.hash_removed(writer, algo, digest)?;
144 } else if new[algo] != *digest {
145 fmt.hash_changed(writer, algo, digest, &new[algo])?;
146 }
147 }
148 for (algo, digest) in new {
149 if !old.contains_key(algo) {
150 fmt.hash_added(writer, algo, digest)?;
151 }
152 }
153 }
154 FieldChange::Ecosystem(old, new) => {
155 fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
156 }
157 }
158 }
159 Ok(())
160}
161
162fn write_changed<F: FieldChangeFormatter, W: Write>(
163 fmt: &F,
164 writer: &mut W,
165 changes: &[ComponentChange],
166) -> std::io::Result<()> {
167 for c in changes {
168 fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
169 write_field_changes(fmt, writer, &c.changes)?;
170 }
171 Ok(())
172}
173
174fn write_text_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
177 for c in components {
178 writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
179 }
180 Ok(())
181}
182
183pub struct TextRenderer;
185
186impl FieldChangeFormatter for TextRenderer {
187 fn field_change<W: Write>(
188 &self,
189 w: &mut W,
190 name: &str,
191 old: &str,
192 new: &str,
193 ) -> std::io::Result<()> {
194 writeln!(w, " {}: {} -> {}", name, old, new)
195 }
196
197 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
198 writeln!(w, " Hashes:")
199 }
200
201 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
202 writeln!(w, " - {}: {}", algo, digest)
203 }
204
205 fn hash_changed<W: Write>(
206 &self,
207 w: &mut W,
208 algo: &str,
209 old: &str,
210 new: &str,
211 ) -> std::io::Result<()> {
212 writeln!(w, " ~ {}: {} -> {}", algo, old, new)
213 }
214
215 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
216 writeln!(w, " + {}: {}", algo, digest)
217 }
218
219 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
220 writeln!(w, "{}", id)
221 }
222}
223
224impl Renderer for TextRenderer {
225 fn render<W: Write>(
226 &self,
227 diff: &Diff,
228 opts: &RenderOptions,
229 writer: &mut W,
230 ) -> anyhow::Result<()> {
231 if opts.has_warnings() {
232 writeln!(writer, "[!] Warnings")?;
233 writeln!(writer, "------------")?;
234 for w in &opts.old_warnings {
235 writeln!(writer, "[old] {}", w)?;
236 }
237 for w in &opts.new_warnings {
238 writeln!(writer, "[new] {}", w)?;
239 }
240 writeln!(writer)?;
241 }
242
243 writeln!(writer, "Diff Summary")?;
244 writeln!(writer, "============")?;
245 writeln!(writer, "Old total: {} components", diff.old_total)?;
246 writeln!(writer, "New total: {} components", diff.new_total)?;
247 writeln!(writer, "Unchanged: {}", diff.unchanged)?;
248 writeln!(writer, "Added: {}", diff.added.len())?;
249 writeln!(writer, "Removed: {}", diff.removed.len())?;
250 writeln!(writer, "Changed: {}", diff.changed.len())?;
251 writeln!(writer)?;
252
253 if opts.group_by_ecosystem {
254 let grouped = diff.group_by_ecosystem();
255 let breakdown = grouped.ecosystem_breakdown();
256
257 writeln!(writer, "By Ecosystem")?;
258 writeln!(writer, "------------")?;
259 for (eco, counts) in &breakdown {
260 writeln!(
261 writer,
262 "{}: {} added, {} removed, {} changed",
263 eco, counts.added, counts.removed, counts.changed
264 )?;
265 }
266 writeln!(writer)?;
267
268 for (eco, eco_diff) in &grouped.by_ecosystem {
269 writeln!(writer, "[{}]", eco)?;
270 writeln!(writer)?;
271 if !eco_diff.added.is_empty() {
272 writeln!(writer, "[+] Added")?;
273 writeln!(writer, "---------")?;
274 write_text_added(writer, &eco_diff.added)?;
275 writeln!(writer)?;
276 }
277 if !eco_diff.removed.is_empty() {
278 writeln!(writer, "[-] Removed")?;
279 writeln!(writer, "-----------")?;
280 write_text_added(writer, &eco_diff.removed)?;
281 writeln!(writer)?;
282 }
283 if !eco_diff.changed.is_empty() {
284 writeln!(writer, "[~] Changed")?;
285 writeln!(writer, "-----------")?;
286 write_changed(self, writer, &eco_diff.changed)?;
287 writeln!(writer)?;
288 }
289 }
290 } else {
291 if !diff.added.is_empty() {
292 writeln!(writer, "[+] Added")?;
293 writeln!(writer, "---------")?;
294 write_text_added(writer, &diff.added)?;
295 writeln!(writer)?;
296 }
297
298 if !diff.removed.is_empty() {
299 writeln!(writer, "[-] Removed")?;
300 writeln!(writer, "-----------")?;
301 write_text_added(writer, &diff.removed)?;
302 writeln!(writer)?;
303 }
304
305 if !diff.changed.is_empty() {
306 writeln!(writer, "[~] Changed")?;
307 writeln!(writer, "-----------")?;
308 write_changed(self, writer, &diff.changed)?;
309 writeln!(writer)?;
310 }
311 }
312
313 if !diff.edge_diffs.is_empty() {
314 writeln!(writer, "[~] Edge Changes")?;
315 writeln!(writer, "----------------")?;
316 for edge in &diff.edge_diffs {
317 writeln!(writer, "{}", diff.display_name(&edge.parent))?;
318 for (removed, kind) in &edge.removed {
319 writeln!(
320 writer,
321 " - {}{}",
322 diff.display_name(removed),
323 kind_suffix(kind)
324 )?;
325 }
326 for (added, kind) in &edge.added {
327 writeln!(
328 writer,
329 " + {}{}",
330 diff.display_name(added),
331 kind_suffix(kind)
332 )?;
333 }
334 for (changed, (old_kind, new_kind)) in &edge.kind_changed {
335 writeln!(
336 writer,
337 " ~ {} ({} -> {})",
338 diff.display_name(changed),
339 old_kind,
340 new_kind
341 )?;
342 }
343 }
344 }
345
346 Ok(())
347 }
348}
349
350fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
353 for c in components {
354 writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
355 }
356 Ok(())
357}
358
359pub struct MarkdownRenderer;
363
364impl FieldChangeFormatter for MarkdownRenderer {
365 fn field_change<W: Write>(
366 &self,
367 w: &mut W,
368 name: &str,
369 old: &str,
370 new: &str,
371 ) -> std::io::Result<()> {
372 writeln!(w, "- **{}**: `{}` → `{}`", name, old, new)
373 }
374
375 fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
376 writeln!(w, "- **Hashes**:")
377 }
378
379 fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
380 writeln!(w, " - `{}`: removed `{}`", algo, digest)
381 }
382
383 fn hash_changed<W: Write>(
384 &self,
385 w: &mut W,
386 algo: &str,
387 old: &str,
388 new: &str,
389 ) -> std::io::Result<()> {
390 writeln!(w, " - `{}`: `{}` → `{}`", algo, old, new)
391 }
392
393 fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
394 writeln!(w, " - `{}`: added `{}`", algo, digest)
395 }
396
397 fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
398 writeln!(w, "#### `{}`", id)
399 }
400}
401
402impl Renderer for MarkdownRenderer {
403 fn render<W: Write>(
404 &self,
405 diff: &Diff,
406 opts: &RenderOptions,
407 writer: &mut W,
408 ) -> anyhow::Result<()> {
409 if opts.has_warnings() {
410 writeln!(
411 writer,
412 "<details><summary><b>Warnings ({})</b></summary>",
413 opts.warning_count()
414 )?;
415 writeln!(writer)?;
416 for w in &opts.old_warnings {
417 writeln!(writer, "- **old:** {}", w)?;
418 }
419 for w in &opts.new_warnings {
420 writeln!(writer, "- **new:** {}", w)?;
421 }
422 writeln!(writer, "</details>")?;
423 writeln!(writer)?;
424 }
425
426 writeln!(writer, "### SBOM Diff Summary")?;
427 writeln!(writer)?;
428 writeln!(writer, "| Metric | Count |")?;
429 writeln!(writer, "| --- | --- |")?;
430 writeln!(writer, "| Old total | {} |", diff.old_total)?;
431 writeln!(writer, "| New total | {} |", diff.new_total)?;
432 writeln!(writer, "| Unchanged | {} |", diff.unchanged)?;
433 writeln!(writer, "| Added | {} |", diff.added.len())?;
434 writeln!(writer, "| Removed | {} |", diff.removed.len())?;
435 writeln!(writer, "| Changed | {} |", diff.changed.len())?;
436 writeln!(writer)?;
437
438 if opts.group_by_ecosystem {
439 let grouped = diff.group_by_ecosystem();
440 let breakdown = grouped.ecosystem_breakdown();
441
442 writeln!(writer, "#### By Ecosystem")?;
443 writeln!(writer)?;
444 writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
445 writeln!(writer, "| --- | --- | --- | --- |")?;
446 for (eco, counts) in &breakdown {
447 writeln!(
448 writer,
449 "| {} | {} | {} | {} |",
450 eco, counts.added, counts.removed, counts.changed
451 )?;
452 }
453 writeln!(writer)?;
454
455 for (eco, eco_diff) in &grouped.by_ecosystem {
456 writeln!(writer, "#### {}", eco)?;
457 writeln!(writer)?;
458 if !eco_diff.added.is_empty() {
459 writeln!(
460 writer,
461 "<details><summary><b>Added ({})</b></summary>",
462 eco_diff.added.len()
463 )?;
464 writeln!(writer)?;
465 write_md_added(writer, &eco_diff.added)?;
466 writeln!(writer, "</details>")?;
467 writeln!(writer)?;
468 }
469 if !eco_diff.removed.is_empty() {
470 writeln!(
471 writer,
472 "<details><summary><b>Removed ({})</b></summary>",
473 eco_diff.removed.len()
474 )?;
475 writeln!(writer)?;
476 write_md_added(writer, &eco_diff.removed)?;
477 writeln!(writer, "</details>")?;
478 writeln!(writer)?;
479 }
480 if !eco_diff.changed.is_empty() {
481 writeln!(
482 writer,
483 "<details><summary><b>Changed ({})</b></summary>",
484 eco_diff.changed.len()
485 )?;
486 writeln!(writer)?;
487 write_changed(self, writer, &eco_diff.changed)?;
488 writeln!(writer, "</details>")?;
489 writeln!(writer)?;
490 }
491 }
492 } else {
493 if !diff.added.is_empty() {
494 writeln!(
495 writer,
496 "<details><summary><b>Added ({})</b></summary>",
497 diff.added.len()
498 )?;
499 writeln!(writer)?;
500 write_md_added(writer, &diff.added)?;
501 writeln!(writer, "</details>")?;
502 writeln!(writer)?;
503 }
504
505 if !diff.removed.is_empty() {
506 writeln!(
507 writer,
508 "<details><summary><b>Removed ({})</b></summary>",
509 diff.removed.len()
510 )?;
511 writeln!(writer)?;
512 write_md_added(writer, &diff.removed)?;
513 writeln!(writer, "</details>")?;
514 writeln!(writer)?;
515 }
516
517 if !diff.changed.is_empty() {
518 writeln!(
519 writer,
520 "<details><summary><b>Changed ({})</b></summary>",
521 diff.changed.len()
522 )?;
523 writeln!(writer)?;
524 write_changed(self, writer, &diff.changed)?;
525 writeln!(writer, "</details>")?;
526 writeln!(writer)?;
527 }
528 }
529
530 if !diff.edge_diffs.is_empty() {
531 writeln!(
532 writer,
533 "<details><summary><b>Edge Changes ({})</b></summary>",
534 diff.edge_diffs.len()
535 )?;
536 writeln!(writer)?;
537 for edge in &diff.edge_diffs {
538 writeln!(writer, "#### `{}`", diff.display_name(&edge.parent))?;
539 if !edge.removed.is_empty() {
540 writeln!(writer, "**Removed dependencies:**")?;
541 for (removed, kind) in &edge.removed {
542 writeln!(
543 writer,
544 "- `{}`{}",
545 diff.display_name(removed),
546 kind_suffix(kind)
547 )?;
548 }
549 }
550 if !edge.added.is_empty() {
551 writeln!(writer, "**Added dependencies:**")?;
552 for (added, kind) in &edge.added {
553 writeln!(
554 writer,
555 "- `{}`{}",
556 diff.display_name(added),
557 kind_suffix(kind)
558 )?;
559 }
560 }
561 if !edge.kind_changed.is_empty() {
562 writeln!(writer, "**Kind changed:**")?;
563 for (changed, (old_kind, new_kind)) in &edge.kind_changed {
564 writeln!(
565 writer,
566 "- `{}`: {} → {}",
567 diff.display_name(changed),
568 old_kind,
569 new_kind
570 )?;
571 }
572 }
573 writeln!(writer)?;
574 }
575 writeln!(writer, "</details>")?;
576 }
577
578 Ok(())
579 }
580}
581
582pub struct JsonRenderer;
589
590#[derive(Serialize)]
592struct JsonOutput<'a> {
593 #[serde(flatten)]
594 diff: &'a Diff,
595 #[serde(skip_serializing_if = "Option::is_none")]
596 ecosystem_breakdown: Option<BTreeMap<String, EcosystemCounts>>,
597 #[serde(skip_serializing_if = "Option::is_none")]
598 by_ecosystem: Option<&'a GroupedDiff>,
599 #[serde(skip_serializing_if = "Option::is_none")]
600 warnings: Option<JsonWarnings<'a>>,
601}
602
603#[derive(Serialize)]
604struct JsonWarnings<'a> {
605 old: &'a Vec<String>,
606 new: &'a Vec<String>,
607}
608
609impl Renderer for JsonRenderer {
610 fn render<W: Write>(
611 &self,
612 diff: &Diff,
613 opts: &RenderOptions,
614 writer: &mut W,
615 ) -> anyhow::Result<()> {
616 let warnings = if opts.has_warnings() {
617 Some(JsonWarnings {
618 old: &opts.old_warnings,
619 new: &opts.new_warnings,
620 })
621 } else {
622 None
623 };
624
625 if opts.group_by_ecosystem {
626 let grouped = diff.group_by_ecosystem();
627 let output = JsonOutput {
628 diff,
629 ecosystem_breakdown: Some(grouped.ecosystem_breakdown()),
630 by_ecosystem: Some(&grouped),
631 warnings,
632 };
633 serde_json::to_writer_pretty(writer, &output)?;
634 } else {
635 let output = JsonOutput {
636 diff,
637 ecosystem_breakdown: None,
638 by_ecosystem: None,
639 warnings,
640 };
641 serde_json::to_writer_pretty(writer, &output)?;
642 }
643 Ok(())
644 }
645}
646
647trait SummaryFormatter {
656 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
657 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
658 fn write_ecosystem_breakdown<W: Write>(
659 &self,
660 w: &mut W,
661 breakdown: &BTreeMap<String, EcosystemCounts>,
662 ) -> std::io::Result<()>;
663}
664
665fn write_summary<F: SummaryFormatter, W: Write>(
666 fmt: &F,
667 diff: &Diff,
668 opts: &RenderOptions,
669 writer: &mut W,
670) -> std::io::Result<()> {
671 if opts.has_warnings() {
672 fmt.write_warnings(writer, opts)?;
673 }
674 fmt.write_counts(writer, diff)?;
675 if opts.group_by_ecosystem {
676 let breakdown = diff.ecosystem_breakdown();
677 if !breakdown.is_empty() {
678 fmt.write_ecosystem_breakdown(writer, &breakdown)?;
679 }
680 }
681 Ok(())
682}
683
684impl SummaryFormatter for TextRenderer {
685 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
686 writeln!(w, "Warnings: {}", opts.warning_count())?;
687 for warning in &opts.old_warnings {
688 writeln!(w, " [old] {}", warning)?;
689 }
690 for warning in &opts.new_warnings {
691 writeln!(w, " [new] {}", warning)?;
692 }
693 writeln!(w)
694 }
695
696 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
697 writeln!(w, "Old total: {} components", diff.old_total)?;
698 writeln!(w, "New total: {} components", diff.new_total)?;
699 writeln!(w, "Unchanged: {}", diff.unchanged)?;
700 writeln!(w, "Added: {}", diff.added.len())?;
701 writeln!(w, "Removed: {}", diff.removed.len())?;
702 writeln!(w, "Changed: {}", diff.changed.len())?;
703 writeln!(w, "Edge changes: {}", diff.edge_diffs.len())
704 }
705
706 fn write_ecosystem_breakdown<W: Write>(
707 &self,
708 w: &mut W,
709 breakdown: &BTreeMap<String, EcosystemCounts>,
710 ) -> std::io::Result<()> {
711 writeln!(w)?;
712 writeln!(w, "By ecosystem:")?;
713 for (eco, counts) in breakdown {
714 writeln!(
715 w,
716 " {}: {} added, {} removed, {} changed",
717 eco, counts.added, counts.removed, counts.changed
718 )?;
719 }
720 Ok(())
721 }
722}
723
724impl SummaryRenderer for TextRenderer {
725 fn render_summary<W: Write>(
726 &self,
727 diff: &Diff,
728 opts: &RenderOptions,
729 writer: &mut W,
730 ) -> anyhow::Result<()> {
731 write_summary(self, diff, opts, writer)?;
732 Ok(())
733 }
734}
735
736impl SummaryFormatter for MarkdownRenderer {
737 fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
738 writeln!(
739 w,
740 "<details><summary><b>Warnings ({})</b></summary>",
741 opts.warning_count()
742 )?;
743 writeln!(w)?;
744 for warning in &opts.old_warnings {
745 writeln!(w, "- **old:** {}", warning)?;
746 }
747 for warning in &opts.new_warnings {
748 writeln!(w, "- **new:** {}", warning)?;
749 }
750 writeln!(w, "</details>")?;
751 writeln!(w)
752 }
753
754 fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
755 writeln!(w, "### SBOM Diff Summary")?;
756 writeln!(w)?;
757 writeln!(w, "| Metric | Count |")?;
758 writeln!(w, "| --- | --- |")?;
759 writeln!(w, "| Old total | {} |", diff.old_total)?;
760 writeln!(w, "| New total | {} |", diff.new_total)?;
761 writeln!(w, "| Unchanged | {} |", diff.unchanged)?;
762 writeln!(w, "| Added | {} |", diff.added.len())?;
763 writeln!(w, "| Removed | {} |", diff.removed.len())?;
764 writeln!(w, "| Changed | {} |", diff.changed.len())?;
765 writeln!(w, "| Edge changes | {} |", diff.edge_diffs.len())
766 }
767
768 fn write_ecosystem_breakdown<W: Write>(
769 &self,
770 w: &mut W,
771 breakdown: &BTreeMap<String, EcosystemCounts>,
772 ) -> std::io::Result<()> {
773 writeln!(w)?;
774 writeln!(w, "#### By Ecosystem")?;
775 writeln!(w)?;
776 writeln!(w, "| Ecosystem | Added | Removed | Changed |")?;
777 writeln!(w, "| --- | --- | --- | --- |")?;
778 for (eco, counts) in breakdown {
779 writeln!(
780 w,
781 "| {} | {} | {} | {} |",
782 eco, counts.added, counts.removed, counts.changed
783 )?;
784 }
785 Ok(())
786 }
787}
788
789impl SummaryRenderer for MarkdownRenderer {
790 fn render_summary<W: Write>(
791 &self,
792 diff: &Diff,
793 opts: &RenderOptions,
794 writer: &mut W,
795 ) -> anyhow::Result<()> {
796 write_summary(self, diff, opts, writer)?;
797 Ok(())
798 }
799}
800
801impl SummaryRenderer for JsonRenderer {
802 fn render_summary<W: Write>(
803 &self,
804 diff: &Diff,
805 opts: &RenderOptions,
806 writer: &mut W,
807 ) -> anyhow::Result<()> {
808 let mut summary = serde_json::json!({
809 "old_total": diff.old_total,
810 "new_total": diff.new_total,
811 "unchanged": diff.unchanged,
812 "added": diff.added.len(),
813 "removed": diff.removed.len(),
814 "changed": diff.changed.len(),
815 "edge_changes": diff.edge_diffs.len(),
816 });
817
818 if opts.has_warnings() {
819 summary["warnings"] = serde_json::json!({
820 "old": opts.old_warnings,
821 "new": opts.new_warnings,
822 });
823 }
824
825 if opts.group_by_ecosystem {
826 let breakdown = diff.ecosystem_breakdown();
827 if !breakdown.is_empty() {
828 summary["ecosystem_breakdown"] =
829 serde_json::to_value(&breakdown).expect("serializable breakdown");
830 }
831 }
832
833 serde_json::to_writer_pretty(writer, &summary)
834 .map_err(|e| anyhow::anyhow!("json summary: {}", e))
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use crate::{ComponentChange, Diff, FieldChange};
842 use sbom_model::Component;
843 use std::collections::BTreeMap;
844
845 fn mock_diff() -> Diff {
846 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
847 let mut c2 = c1.clone();
848 c2.version = Some("1.1".into());
849
850 Diff {
851 added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
852 removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
853 changed: vec![ComponentChange {
854 id: c2.id.clone(),
855 old: c1,
856 new: c2,
857 changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
858 }],
859 edge_diffs: vec![],
860 ..Diff::default()
861 }
862 }
863
864 fn mock_diff_all_field_changes() -> Diff {
865 use sbom_model::{ComponentId, DependencyKind};
866
867 let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
868 let mut c2 = c1.clone();
869 c2.version = Some("1.1".into());
870
871 Diff {
872 added: vec![],
873 removed: vec![],
874 changed: vec![ComponentChange {
875 id: c2.id.clone(),
876 old: c1,
877 new: c2,
878 changes: vec![
879 FieldChange::Version("1.0".into(), "1.1".into()),
880 FieldChange::License(
881 BTreeSet::from(["MIT".into()]),
882 BTreeSet::from(["Apache-2.0".into()]),
883 ),
884 FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
885 FieldChange::Purl(
886 Some("pkg:npm/pkg-a@1.0".into()),
887 Some("pkg:npm/pkg-a@1.1".into()),
888 ),
889 FieldChange::Description(
890 Some("Old description".into()),
891 Some("New description".into()),
892 ),
893 FieldChange::Hashes(
894 BTreeMap::from([("sha256".into(), "aaa".into())]),
895 BTreeMap::from([("sha256".into(), "bbb".into())]),
896 ),
897 FieldChange::Ecosystem(Some("npm".into()), Some("cargo".into())),
898 ],
899 }],
900 edge_diffs: vec![crate::EdgeDiff {
901 parent: ComponentId::new(None, &[("name", "parent")]),
902 added: BTreeMap::from([(
903 ComponentId::new(None, &[("name", "child-b")]),
904 DependencyKind::Runtime,
905 )]),
906 removed: BTreeMap::from([(
907 ComponentId::new(None, &[("name", "child-a")]),
908 DependencyKind::Runtime,
909 )]),
910 kind_changed: BTreeMap::new(),
911 }],
912 ..Diff::default()
913 }
914 }
915
916 fn mock_diff_empty() -> Diff {
917 Diff {
918 added: vec![],
919 removed: vec![],
920 changed: vec![],
921 edge_diffs: vec![],
922 ..Diff::default()
923 }
924 }
925
926 #[test]
927 fn test_text_renderer() {
928 let diff = mock_diff();
929 let mut buf = Vec::new();
930 TextRenderer
931 .render(&diff, &RenderOptions::default(), &mut buf)
932 .unwrap();
933 let out = String::from_utf8(buf).unwrap();
934 assert!(out.contains("Diff Summary"));
935 assert!(out.contains("[+] Added"));
936 assert!(out.contains("[-] Removed"));
937 assert!(out.contains("[~] Changed"));
938 }
939
940 #[test]
941 fn test_text_renderer_all_field_changes() {
942 let diff = mock_diff_all_field_changes();
943 let mut buf = Vec::new();
944 TextRenderer
945 .render(&diff, &RenderOptions::default(), &mut buf)
946 .unwrap();
947 let out = String::from_utf8(buf).unwrap();
948
949 assert!(out.contains("Version: 1.0 -> 1.1"));
950 assert!(out.contains("License:"));
951 assert!(out.contains("MIT"));
952 assert!(out.contains("Apache-2.0"));
953 assert!(out.contains("Supplier:"));
954 assert!(out.contains("Old Corp"));
955 assert!(out.contains("New Corp"));
956 assert!(out.contains("Purl:"));
957 assert!(out.contains("Description:"));
958 assert!(out.contains("Old description"));
959 assert!(out.contains("New description"));
960 assert!(out.contains("Hashes:"));
961 assert!(out.contains("~ sha256: aaa -> bbb"));
962 assert!(out.contains("Ecosystem: npm -> cargo"));
963 assert!(out.contains("[~] Edge Changes"));
964 }
965
966 #[test]
967 fn test_text_renderer_empty_diff() {
968 let diff = mock_diff_empty();
969 let mut buf = Vec::new();
970 TextRenderer
971 .render(&diff, &RenderOptions::default(), &mut buf)
972 .unwrap();
973 let out = String::from_utf8(buf).unwrap();
974
975 assert!(out.contains("Old total: 0 components"));
976 assert!(out.contains("New total: 0 components"));
977 assert!(out.contains("Unchanged: 0"));
978 assert!(out.contains("Added: 0"));
979 assert!(out.contains("Removed: 0"));
980 assert!(out.contains("Changed: 0"));
981 assert!(!out.contains("[+] Added"));
982 assert!(!out.contains("[-] Removed"));
983 assert!(!out.contains("[~] Changed"));
984 }
985
986 #[test]
987 fn test_markdown_renderer() {
988 let diff = mock_diff();
989 let mut buf = Vec::new();
990 MarkdownRenderer
991 .render(&diff, &RenderOptions::default(), &mut buf)
992 .unwrap();
993 let out = String::from_utf8(buf).unwrap();
994 assert!(out.contains("### SBOM Diff Summary"));
995 assert!(out.contains("<details>"));
996 }
997
998 #[test]
999 fn test_markdown_renderer_all_field_changes() {
1000 let diff = mock_diff_all_field_changes();
1001 let mut buf = Vec::new();
1002 MarkdownRenderer
1003 .render(&diff, &RenderOptions::default(), &mut buf)
1004 .unwrap();
1005 let out = String::from_utf8(buf).unwrap();
1006
1007 assert!(out.contains("**Version**"));
1008 assert!(out.contains("**License**"));
1009 assert!(out.contains("**Supplier**"));
1010 assert!(out.contains("**Purl**"));
1011 assert!(out.contains("**Description**"));
1012 assert!(out.contains("**Hashes**:"));
1013 assert!(out.contains("`sha256`: `aaa` → `bbb`"));
1014 assert!(out.contains("**Ecosystem**"));
1015 assert!(out.contains("Edge Changes"));
1016 assert!(out.contains("**Removed dependencies:**"));
1017 assert!(out.contains("**Added dependencies:**"));
1018 }
1019
1020 #[test]
1021 fn test_markdown_renderer_empty_diff() {
1022 let diff = mock_diff_empty();
1023 let mut buf = Vec::new();
1024 MarkdownRenderer
1025 .render(&diff, &RenderOptions::default(), &mut buf)
1026 .unwrap();
1027 let out = String::from_utf8(buf).unwrap();
1028
1029 assert!(out.contains("| Added | 0 |"));
1030 assert!(!out.contains("<details>"));
1031 }
1032
1033 #[test]
1034 fn test_json_renderer() {
1035 let diff = mock_diff();
1036 let mut buf = Vec::new();
1037 JsonRenderer
1038 .render(&diff, &RenderOptions::default(), &mut buf)
1039 .unwrap();
1040 let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1041 }
1042
1043 #[test]
1044 fn test_json_renderer_all_field_changes() {
1045 let diff = mock_diff_all_field_changes();
1046 let mut buf = Vec::new();
1047 JsonRenderer
1048 .render(&diff, &RenderOptions::default(), &mut buf)
1049 .unwrap();
1050 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1051
1052 assert_eq!(val["changed"].as_array().unwrap().len(), 1);
1053 assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 7);
1054 assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
1055 }
1056
1057 #[test]
1058 fn test_json_renderer_roundtrip() {
1059 let diff = mock_diff_all_field_changes();
1060 let mut buf = Vec::new();
1061 JsonRenderer
1062 .render(&diff, &RenderOptions::default(), &mut buf)
1063 .unwrap();
1064
1065 let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
1066 assert_eq!(deserialized.changed.len(), diff.changed.len());
1067 assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
1068 assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
1069 }
1070
1071 fn mock_diff_with_ecosystems() -> Diff {
1072 let mut added_npm = Component::new("express".into(), Some("4.18.0".into()));
1073 added_npm.ecosystem = Some("npm".into());
1074 let mut added_cargo = Component::new("serde".into(), Some("1.0.0".into()));
1075 added_cargo.ecosystem = Some("cargo".into());
1076
1077 let mut removed = Component::new("lodash".into(), Some("4.17.21".into()));
1078 removed.ecosystem = Some("npm".into());
1079
1080 let mut old = Component::new("react".into(), Some("17.0.0".into()));
1081 old.ecosystem = Some("npm".into());
1082 let mut new = old.clone();
1083 new.version = Some("18.0.0".into());
1084
1085 Diff {
1086 added: vec![added_npm, added_cargo],
1087 removed: vec![removed],
1088 changed: vec![ComponentChange {
1089 id: new.id.clone(),
1090 old,
1091 new,
1092 changes: vec![FieldChange::Version("17.0.0".into(), "18.0.0".into())],
1093 }],
1094 edge_diffs: vec![],
1095 ..Diff::default()
1096 }
1097 }
1098
1099 #[test]
1100 fn test_text_renderer_group_by_ecosystem() {
1101 let diff = mock_diff_with_ecosystems();
1102 let opts = RenderOptions {
1103 group_by_ecosystem: true,
1104 ..Default::default()
1105 };
1106 let mut buf = Vec::new();
1107 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1108 let out = String::from_utf8(buf).unwrap();
1109
1110 assert!(out.contains("By Ecosystem"));
1111 assert!(out.contains("cargo: 1 added, 0 removed, 0 changed"));
1112 assert!(out.contains("npm: 1 added, 1 removed, 1 changed"));
1113 }
1114
1115 #[test]
1116 fn test_text_renderer_no_ecosystem_by_default() {
1117 let diff = mock_diff_with_ecosystems();
1118 let mut buf = Vec::new();
1119 TextRenderer
1120 .render(&diff, &RenderOptions::default(), &mut buf)
1121 .unwrap();
1122 let out = String::from_utf8(buf).unwrap();
1123
1124 assert!(!out.contains("By Ecosystem"));
1125 }
1126
1127 #[test]
1128 fn test_markdown_renderer_group_by_ecosystem() {
1129 let diff = mock_diff_with_ecosystems();
1130 let opts = RenderOptions {
1131 group_by_ecosystem: true,
1132 ..Default::default()
1133 };
1134 let mut buf = Vec::new();
1135 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1136 let out = String::from_utf8(buf).unwrap();
1137
1138 assert!(out.contains("#### By Ecosystem"));
1139 assert!(out.contains("| Ecosystem | Added | Removed | Changed |"));
1140 assert!(out.contains("| cargo | 1 | 0 | 0 |"));
1141 assert!(out.contains("| npm | 1 | 1 | 1 |"));
1142 }
1143
1144 #[test]
1145 fn test_json_renderer_group_by_ecosystem() {
1146 let diff = mock_diff_with_ecosystems();
1147 let opts = RenderOptions {
1148 group_by_ecosystem: true,
1149 ..Default::default()
1150 };
1151 let mut buf = Vec::new();
1152 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1153 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1154
1155 let breakdown = &val["ecosystem_breakdown"];
1156 assert!(breakdown.is_object());
1157 assert_eq!(breakdown["npm"]["added"], 1);
1158 assert_eq!(breakdown["npm"]["removed"], 1);
1159 assert_eq!(breakdown["npm"]["changed"], 1);
1160 assert_eq!(breakdown["cargo"]["added"], 1);
1161 assert_eq!(breakdown["cargo"]["removed"], 0);
1162 }
1163
1164 #[test]
1165 fn test_json_renderer_no_ecosystem_by_default() {
1166 let diff = mock_diff_with_ecosystems();
1167 let mut buf = Vec::new();
1168 JsonRenderer
1169 .render(&diff, &RenderOptions::default(), &mut buf)
1170 .unwrap();
1171 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1172
1173 assert!(val.get("ecosystem_breakdown").is_none());
1174 }
1175
1176 fn opts_with_warnings() -> RenderOptions {
1177 RenderOptions {
1178 show_warnings: true,
1179 old_warnings: vec!["SPDX: orphaned ref 'SPDXRef-foo'".into()],
1180 new_warnings: vec!["CycloneDX: unknown bom-ref 'bar'".into()],
1181 ..Default::default()
1182 }
1183 }
1184
1185 #[test]
1186 fn test_text_renderer_shows_warnings() {
1187 let diff = mock_diff();
1188 let opts = opts_with_warnings();
1189 let mut buf = Vec::new();
1190 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1191 let out = String::from_utf8(buf).unwrap();
1192
1193 assert!(out.contains("[!] Warnings"));
1194 assert!(out.contains("[old] SPDX: orphaned ref 'SPDXRef-foo'"));
1195 assert!(out.contains("[new] CycloneDX: unknown bom-ref 'bar'"));
1196 }
1197
1198 #[test]
1199 fn test_text_renderer_hides_warnings_by_default() {
1200 let diff = mock_diff();
1201 let mut buf = Vec::new();
1202 TextRenderer
1203 .render(&diff, &RenderOptions::default(), &mut buf)
1204 .unwrap();
1205 let out = String::from_utf8(buf).unwrap();
1206
1207 assert!(!out.contains("[!] Warnings"));
1208 }
1209
1210 #[test]
1211 fn test_markdown_renderer_shows_warnings() {
1212 let diff = mock_diff();
1213 let opts = opts_with_warnings();
1214 let mut buf = Vec::new();
1215 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1216 let out = String::from_utf8(buf).unwrap();
1217
1218 assert!(out.contains("<details><summary><b>Warnings (2)</b></summary>"));
1219 assert!(out.contains("- **old:** SPDX: orphaned ref 'SPDXRef-foo'"));
1220 assert!(out.contains("- **new:** CycloneDX: unknown bom-ref 'bar'"));
1221 }
1222
1223 #[test]
1224 fn test_markdown_renderer_hides_warnings_by_default() {
1225 let diff = mock_diff();
1226 let mut buf = Vec::new();
1227 MarkdownRenderer
1228 .render(&diff, &RenderOptions::default(), &mut buf)
1229 .unwrap();
1230 let out = String::from_utf8(buf).unwrap();
1231
1232 assert!(!out.contains("Warnings"));
1233 }
1234
1235 #[test]
1236 fn test_json_renderer_shows_warnings() {
1237 let diff = mock_diff();
1238 let opts = opts_with_warnings();
1239 let mut buf = Vec::new();
1240 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1241 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1242
1243 let warnings = &val["warnings"];
1244 let old = warnings["old"].as_array().unwrap();
1245 let new = warnings["new"].as_array().unwrap();
1246 assert_eq!(old.len(), 1);
1247 assert_eq!(new.len(), 1);
1248 assert_eq!(old[0], "SPDX: orphaned ref 'SPDXRef-foo'");
1249 assert_eq!(new[0], "CycloneDX: unknown bom-ref 'bar'");
1250 }
1251
1252 #[test]
1253 fn test_json_renderer_hides_warnings_by_default() {
1254 let diff = mock_diff();
1255 let mut buf = Vec::new();
1256 JsonRenderer
1257 .render(&diff, &RenderOptions::default(), &mut buf)
1258 .unwrap();
1259 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1260
1261 assert!(val.get("warnings").is_none());
1262 }
1263
1264 #[test]
1265 fn test_empty_warnings_not_shown() {
1266 let diff = mock_diff();
1267 let opts = RenderOptions {
1268 show_warnings: true,
1269 ..Default::default()
1270 };
1271
1272 let mut buf = Vec::new();
1273 TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1274 let out = String::from_utf8(buf).unwrap();
1275 assert!(!out.contains("[!] Warnings"));
1276
1277 let mut buf = Vec::new();
1278 MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1279 let out = String::from_utf8(buf).unwrap();
1280 assert!(!out.contains("Warnings"));
1281
1282 let mut buf = Vec::new();
1283 JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1284 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1285 assert!(val.get("warnings").is_none());
1286 }
1287
1288 fn mock_diff_with_hash_edge_diffs() -> Diff {
1289 use sbom_model::{ComponentId, DependencyKind};
1290
1291 let parent_id = ComponentId::new(None, &[("name", "parent")]);
1292 let child_a_id = ComponentId::new(None, &[("name", "child-a")]);
1293 let child_b_id = ComponentId::new(None, &[("name", "child-b")]);
1294
1295 let mut names = BTreeMap::new();
1296 names.insert(parent_id.clone(), "my-app@1.0".to_string());
1297 names.insert(child_a_id.clone(), "old-dep@0.1".to_string());
1298 names.insert(child_b_id.clone(), "new-dep@0.2".to_string());
1299
1300 Diff {
1301 edge_diffs: vec![crate::EdgeDiff {
1302 parent: parent_id,
1303 added: BTreeMap::from([(child_b_id, DependencyKind::Runtime)]),
1304 removed: BTreeMap::from([(child_a_id, DependencyKind::Runtime)]),
1305 kind_changed: BTreeMap::new(),
1306 }],
1307 old_total: 10,
1308 new_total: 12,
1309 unchanged: 5,
1310 component_names: names,
1311 ..Diff::default()
1312 }
1313 }
1314
1315 #[test]
1316 fn test_text_renderer_resolves_edge_diff_names() {
1317 let diff = mock_diff_with_hash_edge_diffs();
1318 let mut buf = Vec::new();
1319 TextRenderer
1320 .render(&diff, &RenderOptions::default(), &mut buf)
1321 .unwrap();
1322 let out = String::from_utf8(buf).unwrap();
1323
1324 assert!(out.contains("my-app@1.0"));
1325 assert!(out.contains("- old-dep@0.1"));
1326 assert!(out.contains("+ new-dep@0.2"));
1327 assert!(!out.contains("h:"));
1329 }
1330
1331 #[test]
1332 fn test_text_renderer_shows_totals() {
1333 let diff = mock_diff_with_hash_edge_diffs();
1334 let mut buf = Vec::new();
1335 TextRenderer
1336 .render(&diff, &RenderOptions::default(), &mut buf)
1337 .unwrap();
1338 let out = String::from_utf8(buf).unwrap();
1339
1340 assert!(out.contains("Old total: 10 components"));
1341 assert!(out.contains("New total: 12 components"));
1342 assert!(out.contains("Unchanged: 5"));
1343 }
1344
1345 #[test]
1346 fn test_markdown_renderer_resolves_edge_diff_names() {
1347 let diff = mock_diff_with_hash_edge_diffs();
1348 let mut buf = Vec::new();
1349 MarkdownRenderer
1350 .render(&diff, &RenderOptions::default(), &mut buf)
1351 .unwrap();
1352 let out = String::from_utf8(buf).unwrap();
1353
1354 assert!(out.contains("`my-app@1.0`"));
1355 assert!(out.contains("`old-dep@0.1`"));
1356 assert!(out.contains("`new-dep@0.2`"));
1357 assert!(!out.contains("h:"));
1358 }
1359
1360 #[test]
1361 fn test_markdown_renderer_shows_totals() {
1362 let diff = mock_diff_with_hash_edge_diffs();
1363 let mut buf = Vec::new();
1364 MarkdownRenderer
1365 .render(&diff, &RenderOptions::default(), &mut buf)
1366 .unwrap();
1367 let out = String::from_utf8(buf).unwrap();
1368
1369 assert!(out.contains("| Old total | 10 |"));
1370 assert!(out.contains("| New total | 12 |"));
1371 assert!(out.contains("| Unchanged | 5 |"));
1372 }
1373
1374 #[test]
1375 fn test_json_renderer_includes_totals() {
1376 let diff = mock_diff_with_hash_edge_diffs();
1377 let mut buf = Vec::new();
1378 JsonRenderer
1379 .render(&diff, &RenderOptions::default(), &mut buf)
1380 .unwrap();
1381 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1382
1383 assert_eq!(val["old_total"], 10);
1384 assert_eq!(val["new_total"], 12);
1385 assert_eq!(val["unchanged"], 5);
1386 }
1387
1388 #[test]
1389 fn test_json_renderer_includes_component_names() {
1390 let diff = mock_diff_with_hash_edge_diffs();
1391 let mut buf = Vec::new();
1392 JsonRenderer
1393 .render(&diff, &RenderOptions::default(), &mut buf)
1394 .unwrap();
1395 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1396
1397 let names = &val["component_names"];
1398 assert!(names.is_object());
1399 assert!(names
1400 .as_object()
1401 .unwrap()
1402 .values()
1403 .any(|v| v == "my-app@1.0"));
1404 }
1405
1406 #[test]
1407 fn test_json_renderer_omits_empty_component_names() {
1408 let diff = mock_diff();
1409 let mut buf = Vec::new();
1410 JsonRenderer
1411 .render(&diff, &RenderOptions::default(), &mut buf)
1412 .unwrap();
1413 let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1414
1415 assert!(val.get("component_names").is_none());
1416 }
1417}