Skip to main content

sbom_diff/
renderer.rs

1//! Output renderers for displaying SBOM diffs.
2//!
3//! This module provides formatters for different output contexts:
4//!
5//! - [`TextRenderer`] - Plain text for terminal output
6//! - [`MarkdownRenderer`] - GitHub-flavored markdown for PR comments
7//! - [`JsonRenderer`] - Machine-readable JSON for tooling integration
8
9use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff};
10use sbom_model::Component;
11use serde::Serialize;
12use std::collections::{BTreeMap, BTreeSet};
13use std::io::Write;
14
15/// Options controlling how diffs are rendered.
16#[derive(Debug, Clone, Default)]
17pub struct RenderOptions {
18    /// When true, include a per-ecosystem breakdown of added/removed/changed counts.
19    pub group_by_ecosystem: bool,
20    /// When true, include parser warnings in the output.
21    pub show_warnings: bool,
22    /// Parser warnings from the old SBOM.
23    pub old_warnings: Vec<String>,
24    /// Parser warnings from the new SBOM.
25    pub new_warnings: Vec<String>,
26}
27
28impl RenderOptions {
29    /// Returns true when warnings should be displayed.
30    pub fn has_warnings(&self) -> bool {
31        self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
32    }
33
34    /// Total number of warnings across both SBOMs.
35    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
52/// Trait for rendering a [`Diff`] to an output stream.
53pub trait Renderer {
54    /// Writes the formatted diff to the provided writer.
55    fn render<W: Write>(
56        &self,
57        diff: &Diff,
58        opts: &RenderOptions,
59        writer: &mut W,
60    ) -> anyhow::Result<()>;
61}
62
63// --- Shared helpers for text output ---
64
65fn 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
144/// Plain text renderer for terminal output.
145pub 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
251// --- Shared helpers for markdown output ---
252
253fn 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**: `{}` &rarr; `{}`", old, new)?;
280            }
281            FieldChange::License(old, new) => {
282                writeln!(
283                    writer,
284                    "- **License**: `{}` &rarr; `{}`",
285                    format_set(old),
286                    format_set(new)
287                )?;
288            }
289            FieldChange::Supplier(old, new) => {
290                writeln!(
291                    writer,
292                    "- **Supplier**: `{}` &rarr; `{}`",
293                    format_option(old),
294                    format_option(new)
295                )?;
296            }
297            FieldChange::Purl(old, new) => {
298                writeln!(
299                    writer,
300                    "- **Purl**: `{}` &rarr; `{}`",
301                    format_option(old),
302                    format_option(new)
303                )?;
304            }
305            FieldChange::Description(old, new) => {
306                writeln!(
307                    writer,
308                    "- **Description**: `{}` &rarr; `{}`",
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                            "  - `{}`: `{}` &rarr; `{}`",
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
337/// GitHub-flavored markdown renderer for PR comments.
338///
339/// Produces collapsible sections using `<details>` tags.
340pub 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
497/// JSON renderer for machine consumption.
498///
499/// Outputs the [`Diff`] struct as pretty-printed JSON. When
500/// `group_by_ecosystem` is set, the output includes an
501/// `ecosystem_breakdown` field with per-ecosystem counts and the
502/// `by_ecosystem` field with grouped component data.
503pub struct JsonRenderer;
504
505/// Wrapper for JSON output that optionally includes ecosystem breakdown.
506#[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` &rarr; `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}