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