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, DependencyKind};
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
40/// Returns a display suffix for a dependency kind.
41/// Runtime dependencies get no suffix (they are the default/common case).
42fn 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
65/// Trait for rendering a [`Diff`] to an output stream.
66pub trait Renderer {
67    /// Writes the formatted diff to the provided writer.
68    fn render<W: Write>(
69        &self,
70        diff: &Diff,
71        opts: &RenderOptions,
72        writer: &mut W,
73    ) -> anyhow::Result<()>;
74}
75
76/// Trait for rendering a summary (counts only, no component details) to an output stream.
77///
78/// Mirrors [`Renderer`] but produces compact output suitable for `--summary` mode.
79pub trait SummaryRenderer {
80    /// Writes a summary-only view of the diff to the provided writer.
81    fn render_summary<W: Write>(
82        &self,
83        diff: &Diff,
84        opts: &RenderOptions,
85        writer: &mut W,
86    ) -> anyhow::Result<()>;
87}
88
89// --- Shared helpers for field-change rendering ---
90
91trait 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
174// --- Text output helpers ---
175
176fn 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
183/// Plain text renderer for terminal output.
184pub 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
350// --- Markdown output helpers ---
351
352fn 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
359/// GitHub-flavored markdown renderer for PR comments.
360///
361/// Produces collapsible sections using `<details>` tags.
362pub 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, "- **{}**: `{}` &rarr; `{}`", 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, "  - `{}`: `{}` &rarr; `{}`", 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                            "- `{}`: {} &rarr; {}",
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
582/// JSON renderer for machine consumption.
583///
584/// Outputs the [`Diff`] struct as pretty-printed JSON. When
585/// `group_by_ecosystem` is set, the output includes an
586/// `ecosystem_breakdown` field with per-ecosystem counts and the
587/// `by_ecosystem` field with grouped component data.
588pub struct JsonRenderer;
589
590/// Wrapper for JSON output that optionally includes ecosystem breakdown.
591#[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
647// --- Summary rendering helpers ---
648
649/// Format-specific building blocks for summary output.
650///
651/// Text and markdown renderers implement this trait; the shared
652/// [`write_summary`] function orchestrates calls in the correct order.
653/// JSON uses a fundamentally different approach (building a single
654/// serializable value) and implements [`SummaryRenderer`] directly.
655trait 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` &rarr; `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        // Should NOT contain raw hash IDs
1328        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}