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//! - [`SarifRenderer`] - SARIF 2.1.0 for GitHub Code Scanning / Azure DevOps
9
10use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff, MetadataChange};
11use sbom_model::{Component, DependencyKind};
12use serde::Serialize;
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Write;
15
16/// Options controlling how diffs are rendered.
17#[derive(Debug, Clone, Default)]
18pub struct RenderOptions {
19    /// When true, include a per-ecosystem breakdown of added/removed/changed counts.
20    pub group_by_ecosystem: bool,
21    /// When true, include parser warnings in the output.
22    pub show_warnings: bool,
23    /// Parser warnings from the old SBOM.
24    pub old_warnings: Vec<String>,
25    /// Parser warnings from the new SBOM.
26    pub new_warnings: Vec<String>,
27}
28
29impl RenderOptions {
30    /// Returns true when warnings should be displayed.
31    pub fn has_warnings(&self) -> bool {
32        self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
33    }
34
35    /// Total number of warnings across both SBOMs.
36    pub fn warning_count(&self) -> usize {
37        self.old_warnings.len() + self.new_warnings.len()
38    }
39}
40
41/// Returns a display suffix for a dependency kind.
42/// Runtime dependencies get no suffix (they are the default/common case).
43fn kind_suffix(kind: &DependencyKind) -> &'static str {
44    match kind {
45        DependencyKind::Runtime => "",
46        DependencyKind::Dev => " (dev)",
47        DependencyKind::Build => " (build)",
48        DependencyKind::Test => " (test)",
49        DependencyKind::Optional => " (optional)",
50        DependencyKind::Provided => " (provided)",
51    }
52}
53
54fn format_option(opt: &Option<String>) -> &str {
55    opt.as_deref().unwrap_or("<none>")
56}
57
58fn format_set(set: &BTreeSet<String>) -> String {
59    if set.is_empty() {
60        "<none>".to_string()
61    } else {
62        set.iter().cloned().collect::<Vec<_>>().join(", ")
63    }
64}
65
66/// Trait for rendering a [`Diff`] to an output stream.
67pub trait Renderer {
68    /// Writes the formatted diff to the provided writer.
69    fn render<W: Write>(
70        &self,
71        diff: &Diff,
72        opts: &RenderOptions,
73        writer: &mut W,
74    ) -> anyhow::Result<()>;
75}
76
77/// Trait for rendering a summary (counts only, no component details) to an output stream.
78///
79/// Mirrors [`Renderer`] but produces compact output suitable for `--summary` mode.
80pub trait SummaryRenderer {
81    /// Writes a summary-only view of the diff to the provided writer.
82    fn render_summary<W: Write>(
83        &self,
84        diff: &Diff,
85        opts: &RenderOptions,
86        writer: &mut W,
87    ) -> anyhow::Result<()>;
88}
89
90// --- Shared helpers for field-change rendering ---
91
92trait FieldChangeFormatter {
93    fn field_change<W: Write>(
94        &self,
95        w: &mut W,
96        name: &str,
97        old: &str,
98        new: &str,
99    ) -> std::io::Result<()>;
100    fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
101    fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
102    fn hash_changed<W: Write>(
103        &self,
104        w: &mut W,
105        algo: &str,
106        old: &str,
107        new: &str,
108    ) -> std::io::Result<()>;
109    fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
110    fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
111}
112
113fn write_field_changes<F: FieldChangeFormatter, W: Write>(
114    fmt: &F,
115    writer: &mut W,
116    changes: &[FieldChange],
117) -> std::io::Result<()> {
118    for change in changes {
119        match change {
120            FieldChange::Version(old, new) => {
121                fmt.field_change(writer, "Version", format_option(old), format_option(new))?;
122            }
123            FieldChange::License(old, new) => {
124                fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
125            }
126            FieldChange::Supplier(old, new) => {
127                fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
128            }
129            FieldChange::Purl(old, new) => {
130                fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
131            }
132            FieldChange::Description(old, new) => {
133                fmt.field_change(
134                    writer,
135                    "Description",
136                    format_option(old),
137                    format_option(new),
138                )?;
139            }
140            FieldChange::Hashes(old, new) => {
141                fmt.hash_header(writer)?;
142                for (algo, digest) in old {
143                    if !new.contains_key(algo) {
144                        fmt.hash_removed(writer, algo, digest)?;
145                    } else if new[algo] != *digest {
146                        fmt.hash_changed(writer, algo, digest, &new[algo])?;
147                    }
148                }
149                for (algo, digest) in new {
150                    if !old.contains_key(algo) {
151                        fmt.hash_added(writer, algo, digest)?;
152                    }
153                }
154            }
155            FieldChange::Ecosystem(old, new) => {
156                fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
157            }
158        }
159    }
160    Ok(())
161}
162
163fn write_changed<F: FieldChangeFormatter, W: Write>(
164    fmt: &F,
165    writer: &mut W,
166    changes: &[ComponentChange],
167) -> std::io::Result<()> {
168    for c in changes {
169        fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
170        write_field_changes(fmt, writer, &c.changes)?;
171    }
172    Ok(())
173}
174
175// --- Text output helpers ---
176
177fn write_text_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
178    for c in components {
179        writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
180    }
181    Ok(())
182}
183
184/// Plain text renderer for terminal output.
185pub struct TextRenderer;
186
187impl FieldChangeFormatter for TextRenderer {
188    fn field_change<W: Write>(
189        &self,
190        w: &mut W,
191        name: &str,
192        old: &str,
193        new: &str,
194    ) -> std::io::Result<()> {
195        writeln!(w, "  {}: {} -> {}", name, old, new)
196    }
197
198    fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
199        writeln!(w, "  Hashes:")
200    }
201
202    fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
203        writeln!(w, "    - {}: {}", algo, digest)
204    }
205
206    fn hash_changed<W: Write>(
207        &self,
208        w: &mut W,
209        algo: &str,
210        old: &str,
211        new: &str,
212    ) -> std::io::Result<()> {
213        writeln!(w, "    ~ {}: {} -> {}", algo, old, new)
214    }
215
216    fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
217        writeln!(w, "    + {}: {}", algo, digest)
218    }
219
220    fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
221        writeln!(w, "{}", id)
222    }
223}
224
225impl Renderer for TextRenderer {
226    fn render<W: Write>(
227        &self,
228        diff: &Diff,
229        opts: &RenderOptions,
230        writer: &mut W,
231    ) -> anyhow::Result<()> {
232        if opts.has_warnings() {
233            writeln!(writer, "[!] Warnings")?;
234            writeln!(writer, "------------")?;
235            for w in &opts.old_warnings {
236                writeln!(writer, "[old] {}", w)?;
237            }
238            for w in &opts.new_warnings {
239                writeln!(writer, "[new] {}", w)?;
240            }
241            writeln!(writer)?;
242        }
243
244        writeln!(writer, "Diff Summary")?;
245        writeln!(writer, "============")?;
246        writeln!(writer, "Old total:        {} components", diff.old_total)?;
247        writeln!(writer, "New total:        {} components", diff.new_total)?;
248        writeln!(writer, "Unchanged:        {}", diff.unchanged)?;
249        writeln!(writer, "Added:            {}", diff.added.len())?;
250        writeln!(writer, "Removed:          {}", diff.removed.len())?;
251        writeln!(writer, "Changed:          {}", diff.changed.len())?;
252        writeln!(writer, "Edge changes:     {}", diff.edge_diffs.len())?;
253        writeln!(
254            writer,
255            "Metadata changed: {}",
256            if diff.metadata_changed.is_some() {
257                "yes"
258            } else {
259                "no"
260            }
261        )?;
262        writeln!(writer)?;
263
264        if opts.group_by_ecosystem {
265            let grouped = diff.group_by_ecosystem();
266            let breakdown = grouped.ecosystem_breakdown();
267
268            writeln!(writer, "By Ecosystem")?;
269            writeln!(writer, "------------")?;
270            for (eco, counts) in &breakdown {
271                writeln!(
272                    writer,
273                    "{}: {} added, {} removed, {} changed",
274                    eco, counts.added, counts.removed, counts.changed
275                )?;
276            }
277            writeln!(writer)?;
278
279            for (eco, eco_diff) in &grouped.by_ecosystem {
280                writeln!(writer, "[{}]", eco)?;
281                writeln!(writer)?;
282                if !eco_diff.added.is_empty() {
283                    writeln!(writer, "[+] Added")?;
284                    writeln!(writer, "---------")?;
285                    write_text_added(writer, &eco_diff.added)?;
286                    writeln!(writer)?;
287                }
288                if !eco_diff.removed.is_empty() {
289                    writeln!(writer, "[-] Removed")?;
290                    writeln!(writer, "-----------")?;
291                    write_text_added(writer, &eco_diff.removed)?;
292                    writeln!(writer)?;
293                }
294                if !eco_diff.changed.is_empty() {
295                    writeln!(writer, "[~] Changed")?;
296                    writeln!(writer, "-----------")?;
297                    write_changed(self, writer, &eco_diff.changed)?;
298                    writeln!(writer)?;
299                }
300            }
301        } else {
302            if !diff.added.is_empty() {
303                writeln!(writer, "[+] Added")?;
304                writeln!(writer, "---------")?;
305                write_text_added(writer, &diff.added)?;
306                writeln!(writer)?;
307            }
308
309            if !diff.removed.is_empty() {
310                writeln!(writer, "[-] Removed")?;
311                writeln!(writer, "-----------")?;
312                write_text_added(writer, &diff.removed)?;
313                writeln!(writer)?;
314            }
315
316            if !diff.changed.is_empty() {
317                writeln!(writer, "[~] Changed")?;
318                writeln!(writer, "-----------")?;
319                write_changed(self, writer, &diff.changed)?;
320                writeln!(writer)?;
321            }
322        }
323
324        if !diff.edge_diffs.is_empty() {
325            writeln!(writer, "[~] Edge Changes")?;
326            writeln!(writer, "----------------")?;
327            for edge in &diff.edge_diffs {
328                writeln!(writer, "{}", diff.display_name(&edge.parent))?;
329                for (removed, kind) in &edge.removed {
330                    writeln!(
331                        writer,
332                        "  - {}{}",
333                        diff.display_name(removed),
334                        kind_suffix(kind)
335                    )?;
336                }
337                for (added, kind) in &edge.added {
338                    writeln!(
339                        writer,
340                        "  + {}{}",
341                        diff.display_name(added),
342                        kind_suffix(kind)
343                    )?;
344                }
345                for (changed, (old_kind, new_kind)) in &edge.kind_changed {
346                    writeln!(
347                        writer,
348                        "  ~ {} ({} -> {})",
349                        diff.display_name(changed),
350                        old_kind,
351                        new_kind
352                    )?;
353                }
354            }
355        }
356
357        if let Some(mc) = &diff.metadata_changed {
358            writeln!(writer)?;
359            write_text_metadata(writer, mc)?;
360        }
361
362        Ok(())
363    }
364}
365
366fn write_text_metadata<W: Write>(writer: &mut W, mc: &MetadataChange) -> std::io::Result<()> {
367    writeln!(writer, "[~] Metadata Changes")?;
368    writeln!(writer, "--------------------")?;
369    if let Some((ref old, ref new)) = mc.timestamp {
370        writeln!(
371            writer,
372            "  Timestamp: {} -> {}",
373            old.as_deref().unwrap_or("<none>"),
374            new.as_deref().unwrap_or("<none>")
375        )?;
376    }
377    if let Some((ref old, ref new)) = mc.tools {
378        writeln!(
379            writer,
380            "  Tools: {} -> {}",
381            format_vec_or_none(old),
382            format_vec_or_none(new)
383        )?;
384    }
385    if let Some((ref old, ref new)) = mc.authors {
386        writeln!(
387            writer,
388            "  Authors: {} -> {}",
389            format_vec_or_none(old),
390            format_vec_or_none(new)
391        )?;
392    }
393    Ok(())
394}
395
396fn format_vec_or_none(v: &[String]) -> String {
397    if v.is_empty() {
398        "<none>".to_string()
399    } else {
400        v.join(", ")
401    }
402}
403
404// --- Markdown output helpers ---
405
406fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
407    for c in components {
408        writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
409    }
410    Ok(())
411}
412
413/// GitHub-flavored markdown renderer for PR comments.
414///
415/// Produces collapsible sections using `<details>` tags.
416pub struct MarkdownRenderer;
417
418impl FieldChangeFormatter for MarkdownRenderer {
419    fn field_change<W: Write>(
420        &self,
421        w: &mut W,
422        name: &str,
423        old: &str,
424        new: &str,
425    ) -> std::io::Result<()> {
426        writeln!(w, "- **{}**: `{}` &rarr; `{}`", name, old, new)
427    }
428
429    fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
430        writeln!(w, "- **Hashes**:")
431    }
432
433    fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
434        writeln!(w, "  - `{}`: removed `{}`", algo, digest)
435    }
436
437    fn hash_changed<W: Write>(
438        &self,
439        w: &mut W,
440        algo: &str,
441        old: &str,
442        new: &str,
443    ) -> std::io::Result<()> {
444        writeln!(w, "  - `{}`: `{}` &rarr; `{}`", algo, old, new)
445    }
446
447    fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()> {
448        writeln!(w, "  - `{}`: added `{}`", algo, digest)
449    }
450
451    fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()> {
452        writeln!(w, "#### `{}`", id)
453    }
454}
455
456impl Renderer for MarkdownRenderer {
457    fn render<W: Write>(
458        &self,
459        diff: &Diff,
460        opts: &RenderOptions,
461        writer: &mut W,
462    ) -> anyhow::Result<()> {
463        if opts.has_warnings() {
464            writeln!(
465                writer,
466                "<details><summary><b>Warnings ({})</b></summary>",
467                opts.warning_count()
468            )?;
469            writeln!(writer)?;
470            for w in &opts.old_warnings {
471                writeln!(writer, "- **old:** {}", w)?;
472            }
473            for w in &opts.new_warnings {
474                writeln!(writer, "- **new:** {}", w)?;
475            }
476            writeln!(writer, "</details>")?;
477            writeln!(writer)?;
478        }
479
480        writeln!(writer, "### SBOM Diff Summary")?;
481        writeln!(writer)?;
482        writeln!(writer, "| Metric | Count |")?;
483        writeln!(writer, "| --- | --- |")?;
484        writeln!(writer, "| Old total | {} |", diff.old_total)?;
485        writeln!(writer, "| New total | {} |", diff.new_total)?;
486        writeln!(writer, "| Unchanged | {} |", diff.unchanged)?;
487        writeln!(writer, "| Added | {} |", diff.added.len())?;
488        writeln!(writer, "| Removed | {} |", diff.removed.len())?;
489        writeln!(writer, "| Changed | {} |", diff.changed.len())?;
490        writeln!(writer, "| Edge changes | {} |", diff.edge_diffs.len())?;
491        writeln!(
492            writer,
493            "| Metadata changed | {} |",
494            if diff.metadata_changed.is_some() {
495                "yes"
496            } else {
497                "no"
498            }
499        )?;
500        writeln!(writer)?;
501
502        if opts.group_by_ecosystem {
503            let grouped = diff.group_by_ecosystem();
504            let breakdown = grouped.ecosystem_breakdown();
505
506            writeln!(writer, "#### By Ecosystem")?;
507            writeln!(writer)?;
508            writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
509            writeln!(writer, "| --- | --- | --- | --- |")?;
510            for (eco, counts) in &breakdown {
511                writeln!(
512                    writer,
513                    "| {} | {} | {} | {} |",
514                    eco, counts.added, counts.removed, counts.changed
515                )?;
516            }
517            writeln!(writer)?;
518
519            for (eco, eco_diff) in &grouped.by_ecosystem {
520                writeln!(writer, "#### {}", eco)?;
521                writeln!(writer)?;
522                if !eco_diff.added.is_empty() {
523                    writeln!(
524                        writer,
525                        "<details><summary><b>Added ({})</b></summary>",
526                        eco_diff.added.len()
527                    )?;
528                    writeln!(writer)?;
529                    write_md_added(writer, &eco_diff.added)?;
530                    writeln!(writer, "</details>")?;
531                    writeln!(writer)?;
532                }
533                if !eco_diff.removed.is_empty() {
534                    writeln!(
535                        writer,
536                        "<details><summary><b>Removed ({})</b></summary>",
537                        eco_diff.removed.len()
538                    )?;
539                    writeln!(writer)?;
540                    write_md_added(writer, &eco_diff.removed)?;
541                    writeln!(writer, "</details>")?;
542                    writeln!(writer)?;
543                }
544                if !eco_diff.changed.is_empty() {
545                    writeln!(
546                        writer,
547                        "<details><summary><b>Changed ({})</b></summary>",
548                        eco_diff.changed.len()
549                    )?;
550                    writeln!(writer)?;
551                    write_changed(self, writer, &eco_diff.changed)?;
552                    writeln!(writer, "</details>")?;
553                    writeln!(writer)?;
554                }
555            }
556        } else {
557            if !diff.added.is_empty() {
558                writeln!(
559                    writer,
560                    "<details><summary><b>Added ({})</b></summary>",
561                    diff.added.len()
562                )?;
563                writeln!(writer)?;
564                write_md_added(writer, &diff.added)?;
565                writeln!(writer, "</details>")?;
566                writeln!(writer)?;
567            }
568
569            if !diff.removed.is_empty() {
570                writeln!(
571                    writer,
572                    "<details><summary><b>Removed ({})</b></summary>",
573                    diff.removed.len()
574                )?;
575                writeln!(writer)?;
576                write_md_added(writer, &diff.removed)?;
577                writeln!(writer, "</details>")?;
578                writeln!(writer)?;
579            }
580
581            if !diff.changed.is_empty() {
582                writeln!(
583                    writer,
584                    "<details><summary><b>Changed ({})</b></summary>",
585                    diff.changed.len()
586                )?;
587                writeln!(writer)?;
588                write_changed(self, writer, &diff.changed)?;
589                writeln!(writer, "</details>")?;
590                writeln!(writer)?;
591            }
592        }
593
594        if !diff.edge_diffs.is_empty() {
595            writeln!(
596                writer,
597                "<details><summary><b>Edge Changes ({})</b></summary>",
598                diff.edge_diffs.len()
599            )?;
600            writeln!(writer)?;
601            for edge in &diff.edge_diffs {
602                writeln!(writer, "#### `{}`", diff.display_name(&edge.parent))?;
603                if !edge.removed.is_empty() {
604                    writeln!(writer, "**Removed dependencies:**")?;
605                    for (removed, kind) in &edge.removed {
606                        writeln!(
607                            writer,
608                            "- `{}`{}",
609                            diff.display_name(removed),
610                            kind_suffix(kind)
611                        )?;
612                    }
613                }
614                if !edge.added.is_empty() {
615                    writeln!(writer, "**Added dependencies:**")?;
616                    for (added, kind) in &edge.added {
617                        writeln!(
618                            writer,
619                            "- `{}`{}",
620                            diff.display_name(added),
621                            kind_suffix(kind)
622                        )?;
623                    }
624                }
625                if !edge.kind_changed.is_empty() {
626                    writeln!(writer, "**Kind changed:**")?;
627                    for (changed, (old_kind, new_kind)) in &edge.kind_changed {
628                        writeln!(
629                            writer,
630                            "- `{}`: {} &rarr; {}",
631                            diff.display_name(changed),
632                            old_kind,
633                            new_kind
634                        )?;
635                    }
636                }
637                writeln!(writer)?;
638            }
639            writeln!(writer, "</details>")?;
640        }
641
642        if let Some(mc) = &diff.metadata_changed {
643            writeln!(writer)?;
644            write_md_metadata(writer, mc)?;
645        }
646
647        Ok(())
648    }
649}
650
651fn write_md_metadata<W: Write>(writer: &mut W, mc: &MetadataChange) -> std::io::Result<()> {
652    writeln!(
653        writer,
654        "<details><summary><b>Metadata Changes</b></summary>"
655    )?;
656    writeln!(writer)?;
657    if let Some((ref old, ref new)) = mc.timestamp {
658        writeln!(
659            writer,
660            "- **Timestamp**: `{}` &rarr; `{}`",
661            old.as_deref().unwrap_or("<none>"),
662            new.as_deref().unwrap_or("<none>")
663        )?;
664    }
665    if let Some((ref old, ref new)) = mc.tools {
666        writeln!(
667            writer,
668            "- **Tools**: `{}` &rarr; `{}`",
669            format_vec_or_none(old),
670            format_vec_or_none(new)
671        )?;
672    }
673    if let Some((ref old, ref new)) = mc.authors {
674        writeln!(
675            writer,
676            "- **Authors**: `{}` &rarr; `{}`",
677            format_vec_or_none(old),
678            format_vec_or_none(new)
679        )?;
680    }
681    writeln!(writer, "</details>")?;
682    Ok(())
683}
684
685/// JSON renderer for machine consumption.
686///
687/// Outputs the [`Diff`] struct as pretty-printed JSON. When
688/// `group_by_ecosystem` is set, the output includes an
689/// `ecosystem_breakdown` field with per-ecosystem counts and the
690/// `by_ecosystem` field with grouped component data.
691pub struct JsonRenderer;
692
693/// Wrapper for JSON output that optionally includes ecosystem breakdown.
694#[derive(Serialize)]
695struct JsonOutput<'a> {
696    #[serde(flatten)]
697    diff: &'a Diff,
698    #[serde(skip_serializing_if = "Option::is_none")]
699    ecosystem_breakdown: Option<BTreeMap<String, EcosystemCounts>>,
700    #[serde(skip_serializing_if = "Option::is_none")]
701    by_ecosystem: Option<&'a GroupedDiff>,
702    #[serde(skip_serializing_if = "Option::is_none")]
703    warnings: Option<JsonWarnings<'a>>,
704}
705
706#[derive(Serialize)]
707struct JsonWarnings<'a> {
708    old: &'a Vec<String>,
709    new: &'a Vec<String>,
710}
711
712impl Renderer for JsonRenderer {
713    fn render<W: Write>(
714        &self,
715        diff: &Diff,
716        opts: &RenderOptions,
717        writer: &mut W,
718    ) -> anyhow::Result<()> {
719        let warnings = if opts.has_warnings() {
720            Some(JsonWarnings {
721                old: &opts.old_warnings,
722                new: &opts.new_warnings,
723            })
724        } else {
725            None
726        };
727
728        if opts.group_by_ecosystem {
729            let grouped = diff.group_by_ecosystem();
730            let output = JsonOutput {
731                diff,
732                ecosystem_breakdown: Some(grouped.ecosystem_breakdown()),
733                by_ecosystem: Some(&grouped),
734                warnings,
735            };
736            serde_json::to_writer_pretty(writer, &output)?;
737        } else {
738            let output = JsonOutput {
739                diff,
740                ecosystem_breakdown: None,
741                by_ecosystem: None,
742                warnings,
743            };
744            serde_json::to_writer_pretty(writer, &output)?;
745        }
746        Ok(())
747    }
748}
749
750// --- SARIF 2.1.0 output ---
751
752const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
753const SARIF_VERSION: &str = "2.1.0";
754
755// Rule indices (must match order in SARIF_RULES)
756const RULE_COMPONENT_ADDED: usize = 0;
757const RULE_COMPONENT_REMOVED: usize = 1;
758const RULE_COMPONENT_CHANGED: usize = 2;
759const RULE_DEPENDENCY_CHANGED: usize = 3;
760const RULE_METADATA_CHANGED: usize = 4;
761const RULE_PARSER_WARNING: usize = 5;
762
763#[derive(Clone, Copy)]
764struct RuleInfo {
765    id: &'static str,
766    short_desc: &'static str,
767    full_desc: &'static str,
768    level: &'static str,
769}
770
771const SARIF_RULES: &[RuleInfo] = &[
772    RuleInfo {
773        id: "component-added",
774        short_desc: "Component added",
775        full_desc: "A new component was added to the SBOM",
776        level: "note",
777    },
778    RuleInfo {
779        id: "component-removed",
780        short_desc: "Component removed",
781        full_desc: "A component was removed from the SBOM",
782        level: "warning",
783    },
784    RuleInfo {
785        id: "component-changed",
786        short_desc: "Component changed",
787        full_desc: "A component's metadata changed between SBOMs",
788        level: "warning",
789    },
790    RuleInfo {
791        id: "dependency-changed",
792        short_desc: "Dependency changed",
793        full_desc: "A dependency edge was added, removed, or changed kind",
794        level: "note",
795    },
796    RuleInfo {
797        id: "metadata-changed",
798        short_desc: "Metadata changed",
799        full_desc: "Document metadata (timestamp, tools, or authors) changed between SBOMs",
800        level: "note",
801    },
802    RuleInfo {
803        id: "parser-warning",
804        short_desc: "Parser warning",
805        full_desc: "The SBOM parser emitted a warning about the input document",
806        level: "note",
807    },
808];
809
810#[derive(Serialize)]
811struct SarifLog {
812    #[serde(rename = "$schema")]
813    schema: &'static str,
814    version: &'static str,
815    runs: Vec<SarifRun>,
816}
817
818#[derive(Serialize)]
819struct SarifRun {
820    tool: SarifTool,
821    results: Vec<SarifResultEntry>,
822}
823
824#[derive(Serialize)]
825struct SarifTool {
826    driver: SarifDriverInfo,
827}
828
829#[derive(Serialize)]
830#[serde(rename_all = "camelCase")]
831struct SarifDriverInfo {
832    name: &'static str,
833    version: &'static str,
834    information_uri: &'static str,
835    rules: Vec<SarifRuleDescriptor>,
836}
837
838#[derive(Serialize)]
839#[serde(rename_all = "camelCase")]
840struct SarifRuleDescriptor {
841    id: &'static str,
842    short_description: SarifMultiformatMessage,
843    full_description: SarifMultiformatMessage,
844    default_configuration: SarifDefaultConfiguration,
845}
846
847#[derive(Serialize)]
848struct SarifDefaultConfiguration {
849    level: &'static str,
850}
851
852#[derive(Serialize)]
853struct SarifMultiformatMessage {
854    text: &'static str,
855}
856
857#[derive(Serialize)]
858#[serde(rename_all = "camelCase")]
859struct SarifResultEntry {
860    rule_id: &'static str,
861    rule_index: usize,
862    level: &'static str,
863    message: SarifTextMessage,
864    locations: Vec<SarifLocation>,
865}
866
867#[derive(Serialize)]
868struct SarifTextMessage {
869    text: String,
870}
871
872#[derive(Serialize)]
873#[serde(rename_all = "camelCase")]
874struct SarifLocation {
875    logical_locations: Vec<SarifLogicalLocation>,
876}
877
878#[derive(Serialize)]
879#[serde(rename_all = "camelCase")]
880struct SarifLogicalLocation {
881    fully_qualified_name: String,
882    kind: &'static str,
883}
884
885/// SARIF 2.1.0 renderer for GitHub Code Scanning integration.
886///
887/// Produces a SARIF log with one run containing rules for each change type
888/// (component added/removed/changed, dependency changed, metadata changed)
889/// and a result entry per finding.
890pub struct SarifRenderer;
891
892impl SarifRenderer {
893    fn build_rules() -> Vec<SarifRuleDescriptor> {
894        SARIF_RULES
895            .iter()
896            .map(|r| SarifRuleDescriptor {
897                id: r.id,
898                short_description: SarifMultiformatMessage { text: r.short_desc },
899                full_description: SarifMultiformatMessage { text: r.full_desc },
900                default_configuration: SarifDefaultConfiguration { level: r.level },
901            })
902            .collect()
903    }
904
905    fn component_display(comp: &Component) -> &str {
906        comp.purl.as_deref().unwrap_or(comp.id.as_str())
907    }
908
909    fn component_location(comp: &Component) -> Vec<SarifLocation> {
910        vec![SarifLocation {
911            logical_locations: vec![SarifLogicalLocation {
912                fully_qualified_name: Self::component_display(comp).to_string(),
913                kind: "package",
914            }],
915        }]
916    }
917
918    fn format_field_change(fc: &FieldChange) -> String {
919        match fc {
920            FieldChange::Version(old, new) => {
921                format!("version: {} -> {}", format_option(old), format_option(new))
922            }
923            FieldChange::License(old, new) => {
924                format!("license: {} -> {}", format_set(old), format_set(new))
925            }
926            FieldChange::Supplier(old, new) => {
927                format!("supplier: {} -> {}", format_option(old), format_option(new))
928            }
929            FieldChange::Purl(old, new) => {
930                format!("purl: {} -> {}", format_option(old), format_option(new))
931            }
932            FieldChange::Description(old, new) => {
933                format!(
934                    "description: {} -> {}",
935                    format_option(old),
936                    format_option(new)
937                )
938            }
939            FieldChange::Hashes(_, _) => "hashes changed".to_string(),
940            FieldChange::Ecosystem(old, new) => {
941                format!(
942                    "ecosystem: {} -> {}",
943                    format_option(old),
944                    format_option(new)
945                )
946            }
947        }
948    }
949
950    fn build_results(diff: &Diff, opts: &RenderOptions) -> Vec<SarifResultEntry> {
951        let mut results = Vec::new();
952
953        if opts.has_warnings() {
954            for w in &opts.old_warnings {
955                results.push(SarifResultEntry {
956                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
957                    rule_index: RULE_PARSER_WARNING,
958                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
959                    message: SarifTextMessage {
960                        text: format!("Parser warning (old SBOM): {}", w),
961                    },
962                    locations: vec![SarifLocation {
963                        logical_locations: vec![SarifLogicalLocation {
964                            fully_qualified_name: "old-sbom".to_string(),
965                            kind: "module",
966                        }],
967                    }],
968                });
969            }
970            for w in &opts.new_warnings {
971                results.push(SarifResultEntry {
972                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
973                    rule_index: RULE_PARSER_WARNING,
974                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
975                    message: SarifTextMessage {
976                        text: format!("Parser warning (new SBOM): {}", w),
977                    },
978                    locations: vec![SarifLocation {
979                        logical_locations: vec![SarifLogicalLocation {
980                            fully_qualified_name: "new-sbom".to_string(),
981                            kind: "module",
982                        }],
983                    }],
984                });
985            }
986        }
987
988        for comp in &diff.added {
989            results.push(SarifResultEntry {
990                rule_id: SARIF_RULES[RULE_COMPONENT_ADDED].id,
991                rule_index: RULE_COMPONENT_ADDED,
992                level: SARIF_RULES[RULE_COMPONENT_ADDED].level,
993                message: SarifTextMessage {
994                    text: format!("Component added: {}", Self::component_display(comp)),
995                },
996                locations: Self::component_location(comp),
997            });
998        }
999
1000        for comp in &diff.removed {
1001            results.push(SarifResultEntry {
1002                rule_id: SARIF_RULES[RULE_COMPONENT_REMOVED].id,
1003                rule_index: RULE_COMPONENT_REMOVED,
1004                level: SARIF_RULES[RULE_COMPONENT_REMOVED].level,
1005                message: SarifTextMessage {
1006                    text: format!("Component removed: {}", Self::component_display(comp)),
1007                },
1008                locations: Self::component_location(comp),
1009            });
1010        }
1011
1012        for change in &diff.changed {
1013            let display = Self::component_display(&change.new);
1014            let field_changes: Vec<String> = change
1015                .changes
1016                .iter()
1017                .map(Self::format_field_change)
1018                .collect();
1019
1020            results.push(SarifResultEntry {
1021                rule_id: SARIF_RULES[RULE_COMPONENT_CHANGED].id,
1022                rule_index: RULE_COMPONENT_CHANGED,
1023                level: SARIF_RULES[RULE_COMPONENT_CHANGED].level,
1024                message: SarifTextMessage {
1025                    text: format!(
1026                        "Component changed: {} ({})",
1027                        display,
1028                        field_changes.join("; "),
1029                    ),
1030                },
1031                locations: Self::component_location(&change.new),
1032            });
1033        }
1034
1035        for edge in &diff.edge_diffs {
1036            let parent = diff.display_name(&edge.parent);
1037            let mut parts = Vec::new();
1038
1039            for (child, kind) in &edge.added {
1040                parts.push(format!(
1041                    "added {} -> {}{}",
1042                    parent,
1043                    diff.display_name(child),
1044                    kind_suffix(kind)
1045                ));
1046            }
1047            for (child, kind) in &edge.removed {
1048                parts.push(format!(
1049                    "removed {} -> {}{}",
1050                    parent,
1051                    diff.display_name(child),
1052                    kind_suffix(kind)
1053                ));
1054            }
1055            for (child, (old_kind, new_kind)) in &edge.kind_changed {
1056                parts.push(format!(
1057                    "{} -> {} kind: {} -> {}",
1058                    parent,
1059                    diff.display_name(child),
1060                    old_kind,
1061                    new_kind
1062                ));
1063            }
1064
1065            if !parts.is_empty() {
1066                results.push(SarifResultEntry {
1067                    rule_id: SARIF_RULES[RULE_DEPENDENCY_CHANGED].id,
1068                    rule_index: RULE_DEPENDENCY_CHANGED,
1069                    level: SARIF_RULES[RULE_DEPENDENCY_CHANGED].level,
1070                    message: SarifTextMessage {
1071                        text: format!("Dependency changed: {}", parts.join("; ")),
1072                    },
1073                    locations: vec![SarifLocation {
1074                        logical_locations: vec![SarifLogicalLocation {
1075                            fully_qualified_name: parent.to_string(),
1076                            kind: "package",
1077                        }],
1078                    }],
1079                });
1080            }
1081        }
1082
1083        if let Some(mc) = &diff.metadata_changed {
1084            let mut parts = Vec::new();
1085            if let Some((ref old, ref new)) = mc.timestamp {
1086                parts.push(format!(
1087                    "timestamp: {} -> {}",
1088                    old.as_deref().unwrap_or("<none>"),
1089                    new.as_deref().unwrap_or("<none>")
1090                ));
1091            }
1092            if let Some((ref old, ref new)) = mc.tools {
1093                parts.push(format!(
1094                    "tools: {} -> {}",
1095                    format_vec_or_none(old),
1096                    format_vec_or_none(new)
1097                ));
1098            }
1099            if let Some((ref old, ref new)) = mc.authors {
1100                parts.push(format!(
1101                    "authors: {} -> {}",
1102                    format_vec_or_none(old),
1103                    format_vec_or_none(new)
1104                ));
1105            }
1106
1107            if !parts.is_empty() {
1108                results.push(SarifResultEntry {
1109                    rule_id: SARIF_RULES[RULE_METADATA_CHANGED].id,
1110                    rule_index: RULE_METADATA_CHANGED,
1111                    level: SARIF_RULES[RULE_METADATA_CHANGED].level,
1112                    message: SarifTextMessage {
1113                        text: format!("Metadata changed: {}", parts.join("; ")),
1114                    },
1115                    locations: vec![SarifLocation {
1116                        logical_locations: vec![SarifLogicalLocation {
1117                            fully_qualified_name: "metadata".to_string(),
1118                            kind: "module",
1119                        }],
1120                    }],
1121                });
1122            }
1123        }
1124
1125        results
1126    }
1127}
1128
1129impl Renderer for SarifRenderer {
1130    fn render<W: Write>(
1131        &self,
1132        diff: &Diff,
1133        opts: &RenderOptions,
1134        writer: &mut W,
1135    ) -> anyhow::Result<()> {
1136        let log = SarifLog {
1137            schema: SARIF_SCHEMA,
1138            version: SARIF_VERSION,
1139            runs: vec![SarifRun {
1140                tool: SarifTool {
1141                    driver: SarifDriverInfo {
1142                        name: "sbom-diff",
1143                        version: env!("CARGO_PKG_VERSION"),
1144                        information_uri: "https://github.com/cyberwitchery/sbom-diff",
1145                        rules: Self::build_rules(),
1146                    },
1147                },
1148                results: Self::build_results(diff, opts),
1149            }],
1150        };
1151        serde_json::to_writer_pretty(writer, &log)?;
1152        Ok(())
1153    }
1154}
1155
1156impl SummaryRenderer for SarifRenderer {
1157    fn render_summary<W: Write>(
1158        &self,
1159        diff: &Diff,
1160        opts: &RenderOptions,
1161        writer: &mut W,
1162    ) -> anyhow::Result<()> {
1163        self.render(diff, opts, writer)
1164    }
1165}
1166
1167// --- Summary rendering helpers ---
1168
1169/// Format-specific building blocks for summary output.
1170///
1171/// Text and markdown renderers implement this trait; the shared
1172/// [`write_summary`] function orchestrates calls in the correct order.
1173/// JSON uses a fundamentally different approach (building a single
1174/// serializable value) and implements [`SummaryRenderer`] directly.
1175trait SummaryFormatter {
1176    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
1177    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
1178    fn write_ecosystem_breakdown<W: Write>(
1179        &self,
1180        w: &mut W,
1181        breakdown: &BTreeMap<String, EcosystemCounts>,
1182    ) -> std::io::Result<()>;
1183}
1184
1185fn write_summary<F: SummaryFormatter, W: Write>(
1186    fmt: &F,
1187    diff: &Diff,
1188    opts: &RenderOptions,
1189    writer: &mut W,
1190) -> std::io::Result<()> {
1191    if opts.has_warnings() {
1192        fmt.write_warnings(writer, opts)?;
1193    }
1194    fmt.write_counts(writer, diff)?;
1195    if opts.group_by_ecosystem {
1196        let breakdown = diff.ecosystem_breakdown();
1197        if !breakdown.is_empty() {
1198            fmt.write_ecosystem_breakdown(writer, &breakdown)?;
1199        }
1200    }
1201    Ok(())
1202}
1203
1204impl SummaryFormatter for TextRenderer {
1205    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
1206        writeln!(w, "Warnings:     {}", opts.warning_count())?;
1207        for warning in &opts.old_warnings {
1208            writeln!(w, "  [old] {}", warning)?;
1209        }
1210        for warning in &opts.new_warnings {
1211            writeln!(w, "  [new] {}", warning)?;
1212        }
1213        writeln!(w)
1214    }
1215
1216    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
1217        writeln!(w, "Old total:        {} components", diff.old_total)?;
1218        writeln!(w, "New total:        {} components", diff.new_total)?;
1219        writeln!(w, "Unchanged:        {}", diff.unchanged)?;
1220        writeln!(w, "Added:            {}", diff.added.len())?;
1221        writeln!(w, "Removed:          {}", diff.removed.len())?;
1222        writeln!(w, "Changed:          {}", diff.changed.len())?;
1223        writeln!(w, "Edge changes:     {}", diff.edge_diffs.len())?;
1224        writeln!(
1225            w,
1226            "Metadata changed: {}",
1227            if diff.metadata_changed.is_some() {
1228                "yes"
1229            } else {
1230                "no"
1231            }
1232        )
1233    }
1234
1235    fn write_ecosystem_breakdown<W: Write>(
1236        &self,
1237        w: &mut W,
1238        breakdown: &BTreeMap<String, EcosystemCounts>,
1239    ) -> std::io::Result<()> {
1240        writeln!(w)?;
1241        writeln!(w, "By ecosystem:")?;
1242        for (eco, counts) in breakdown {
1243            writeln!(
1244                w,
1245                "  {}: {} added, {} removed, {} changed",
1246                eco, counts.added, counts.removed, counts.changed
1247            )?;
1248        }
1249        Ok(())
1250    }
1251}
1252
1253impl SummaryRenderer for TextRenderer {
1254    fn render_summary<W: Write>(
1255        &self,
1256        diff: &Diff,
1257        opts: &RenderOptions,
1258        writer: &mut W,
1259    ) -> anyhow::Result<()> {
1260        write_summary(self, diff, opts, writer)?;
1261        Ok(())
1262    }
1263}
1264
1265impl SummaryFormatter for MarkdownRenderer {
1266    fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()> {
1267        writeln!(
1268            w,
1269            "<details><summary><b>Warnings ({})</b></summary>",
1270            opts.warning_count()
1271        )?;
1272        writeln!(w)?;
1273        for warning in &opts.old_warnings {
1274            writeln!(w, "- **old:** {}", warning)?;
1275        }
1276        for warning in &opts.new_warnings {
1277            writeln!(w, "- **new:** {}", warning)?;
1278        }
1279        writeln!(w, "</details>")?;
1280        writeln!(w)
1281    }
1282
1283    fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()> {
1284        writeln!(w, "### SBOM Diff Summary")?;
1285        writeln!(w)?;
1286        writeln!(w, "| Metric | Count |")?;
1287        writeln!(w, "| --- | --- |")?;
1288        writeln!(w, "| Old total | {} |", diff.old_total)?;
1289        writeln!(w, "| New total | {} |", diff.new_total)?;
1290        writeln!(w, "| Unchanged | {} |", diff.unchanged)?;
1291        writeln!(w, "| Added | {} |", diff.added.len())?;
1292        writeln!(w, "| Removed | {} |", diff.removed.len())?;
1293        writeln!(w, "| Changed | {} |", diff.changed.len())?;
1294        writeln!(w, "| Edge changes | {} |", diff.edge_diffs.len())?;
1295        writeln!(
1296            w,
1297            "| Metadata changed | {} |",
1298            if diff.metadata_changed.is_some() {
1299                "yes"
1300            } else {
1301                "no"
1302            }
1303        )
1304    }
1305
1306    fn write_ecosystem_breakdown<W: Write>(
1307        &self,
1308        w: &mut W,
1309        breakdown: &BTreeMap<String, EcosystemCounts>,
1310    ) -> std::io::Result<()> {
1311        writeln!(w)?;
1312        writeln!(w, "#### By Ecosystem")?;
1313        writeln!(w)?;
1314        writeln!(w, "| Ecosystem | Added | Removed | Changed |")?;
1315        writeln!(w, "| --- | --- | --- | --- |")?;
1316        for (eco, counts) in breakdown {
1317            writeln!(
1318                w,
1319                "| {} | {} | {} | {} |",
1320                eco, counts.added, counts.removed, counts.changed
1321            )?;
1322        }
1323        Ok(())
1324    }
1325}
1326
1327impl SummaryRenderer for MarkdownRenderer {
1328    fn render_summary<W: Write>(
1329        &self,
1330        diff: &Diff,
1331        opts: &RenderOptions,
1332        writer: &mut W,
1333    ) -> anyhow::Result<()> {
1334        write_summary(self, diff, opts, writer)?;
1335        Ok(())
1336    }
1337}
1338
1339impl SummaryRenderer for JsonRenderer {
1340    fn render_summary<W: Write>(
1341        &self,
1342        diff: &Diff,
1343        opts: &RenderOptions,
1344        writer: &mut W,
1345    ) -> anyhow::Result<()> {
1346        let mut summary = serde_json::json!({
1347            "old_total": diff.old_total,
1348            "new_total": diff.new_total,
1349            "unchanged": diff.unchanged,
1350            "added": diff.added.len(),
1351            "removed": diff.removed.len(),
1352            "changed": diff.changed.len(),
1353            "edge_changes": diff.edge_diffs.len(),
1354            "metadata_changed": diff.metadata_changed.is_some(),
1355        });
1356
1357        if let Some(mc) = &diff.metadata_changed {
1358            summary["metadata_changes"] = serde_json::to_value(mc)?;
1359        }
1360
1361        if opts.has_warnings() {
1362            summary["warnings"] = serde_json::json!({
1363                "old": opts.old_warnings,
1364                "new": opts.new_warnings,
1365            });
1366        }
1367
1368        if opts.group_by_ecosystem {
1369            let breakdown = diff.ecosystem_breakdown();
1370            if !breakdown.is_empty() {
1371                summary["ecosystem_breakdown"] = serde_json::to_value(&breakdown)?;
1372            }
1373        }
1374
1375        serde_json::to_writer_pretty(writer, &summary)
1376            .map_err(|e| anyhow::anyhow!("json summary: {}", e))
1377    }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383    use crate::{ComponentChange, Diff, FieldChange};
1384    use sbom_model::Component;
1385    use std::collections::BTreeMap;
1386
1387    fn mock_diff() -> Diff {
1388        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
1389        let mut c2 = c1.clone();
1390        c2.version = Some("1.1".into());
1391
1392        Diff {
1393            added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
1394            removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
1395            changed: vec![ComponentChange {
1396                id: c2.id.clone(),
1397                old: c1,
1398                new: c2,
1399                changes: vec![FieldChange::Version(Some("1.0".into()), Some("1.1".into()))],
1400            }],
1401            edge_diffs: vec![],
1402            ..Diff::default()
1403        }
1404    }
1405
1406    fn mock_diff_all_field_changes() -> Diff {
1407        use sbom_model::{ComponentId, DependencyKind};
1408
1409        let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
1410        let mut c2 = c1.clone();
1411        c2.version = Some("1.1".into());
1412
1413        Diff {
1414            added: vec![],
1415            removed: vec![],
1416            changed: vec![ComponentChange {
1417                id: c2.id.clone(),
1418                old: c1,
1419                new: c2,
1420                changes: vec![
1421                    FieldChange::Version(Some("1.0".into()), Some("1.1".into())),
1422                    FieldChange::License(
1423                        BTreeSet::from(["MIT".into()]),
1424                        BTreeSet::from(["Apache-2.0".into()]),
1425                    ),
1426                    FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
1427                    FieldChange::Purl(
1428                        Some("pkg:npm/pkg-a@1.0".into()),
1429                        Some("pkg:npm/pkg-a@1.1".into()),
1430                    ),
1431                    FieldChange::Description(
1432                        Some("Old description".into()),
1433                        Some("New description".into()),
1434                    ),
1435                    FieldChange::Hashes(
1436                        BTreeMap::from([("sha256".into(), "aaa".into())]),
1437                        BTreeMap::from([("sha256".into(), "bbb".into())]),
1438                    ),
1439                    FieldChange::Ecosystem(Some("npm".into()), Some("cargo".into())),
1440                ],
1441            }],
1442            edge_diffs: vec![crate::EdgeDiff {
1443                parent: ComponentId::new(None, &[("name", "parent")]),
1444                added: BTreeMap::from([(
1445                    ComponentId::new(None, &[("name", "child-b")]),
1446                    DependencyKind::Runtime,
1447                )]),
1448                removed: BTreeMap::from([(
1449                    ComponentId::new(None, &[("name", "child-a")]),
1450                    DependencyKind::Runtime,
1451                )]),
1452                kind_changed: BTreeMap::new(),
1453            }],
1454            ..Diff::default()
1455        }
1456    }
1457
1458    fn mock_diff_empty() -> Diff {
1459        Diff {
1460            added: vec![],
1461            removed: vec![],
1462            changed: vec![],
1463            edge_diffs: vec![],
1464            ..Diff::default()
1465        }
1466    }
1467
1468    #[test]
1469    fn test_text_renderer() {
1470        let diff = mock_diff();
1471        let mut buf = Vec::new();
1472        TextRenderer
1473            .render(&diff, &RenderOptions::default(), &mut buf)
1474            .unwrap();
1475        let out = String::from_utf8(buf).unwrap();
1476        assert!(out.contains("Diff Summary"));
1477        assert!(out.contains("[+] Added"));
1478        assert!(out.contains("[-] Removed"));
1479        assert!(out.contains("[~] Changed"));
1480    }
1481
1482    #[test]
1483    fn test_text_renderer_all_field_changes() {
1484        let diff = mock_diff_all_field_changes();
1485        let mut buf = Vec::new();
1486        TextRenderer
1487            .render(&diff, &RenderOptions::default(), &mut buf)
1488            .unwrap();
1489        let out = String::from_utf8(buf).unwrap();
1490
1491        assert!(out.contains("Version: 1.0 -> 1.1"));
1492        assert!(out.contains("License:"));
1493        assert!(out.contains("MIT"));
1494        assert!(out.contains("Apache-2.0"));
1495        assert!(out.contains("Supplier:"));
1496        assert!(out.contains("Old Corp"));
1497        assert!(out.contains("New Corp"));
1498        assert!(out.contains("Purl:"));
1499        assert!(out.contains("Description:"));
1500        assert!(out.contains("Old description"));
1501        assert!(out.contains("New description"));
1502        assert!(out.contains("Hashes:"));
1503        assert!(out.contains("~ sha256: aaa -> bbb"));
1504        assert!(out.contains("Ecosystem: npm -> cargo"));
1505        assert!(out.contains("[~] Edge Changes"));
1506    }
1507
1508    #[test]
1509    fn test_text_renderer_empty_diff() {
1510        let diff = mock_diff_empty();
1511        let mut buf = Vec::new();
1512        TextRenderer
1513            .render(&diff, &RenderOptions::default(), &mut buf)
1514            .unwrap();
1515        let out = String::from_utf8(buf).unwrap();
1516
1517        assert!(out.contains("Old total:        0 components"));
1518        assert!(out.contains("New total:        0 components"));
1519        assert!(out.contains("Unchanged:        0"));
1520        assert!(out.contains("Added:            0"));
1521        assert!(out.contains("Removed:          0"));
1522        assert!(out.contains("Changed:          0"));
1523        assert!(out.contains("Edge changes:     0"));
1524        assert!(out.contains("Metadata changed: no"));
1525        assert!(!out.contains("[+] Added"));
1526        assert!(!out.contains("[-] Removed"));
1527        assert!(!out.contains("[~] Changed"));
1528    }
1529
1530    #[test]
1531    fn test_markdown_renderer() {
1532        let diff = mock_diff();
1533        let mut buf = Vec::new();
1534        MarkdownRenderer
1535            .render(&diff, &RenderOptions::default(), &mut buf)
1536            .unwrap();
1537        let out = String::from_utf8(buf).unwrap();
1538        assert!(out.contains("### SBOM Diff Summary"));
1539        assert!(out.contains("<details>"));
1540    }
1541
1542    #[test]
1543    fn test_markdown_renderer_all_field_changes() {
1544        let diff = mock_diff_all_field_changes();
1545        let mut buf = Vec::new();
1546        MarkdownRenderer
1547            .render(&diff, &RenderOptions::default(), &mut buf)
1548            .unwrap();
1549        let out = String::from_utf8(buf).unwrap();
1550
1551        assert!(out.contains("**Version**"));
1552        assert!(out.contains("**License**"));
1553        assert!(out.contains("**Supplier**"));
1554        assert!(out.contains("**Purl**"));
1555        assert!(out.contains("**Description**"));
1556        assert!(out.contains("**Hashes**:"));
1557        assert!(out.contains("`sha256`: `aaa` &rarr; `bbb`"));
1558        assert!(out.contains("**Ecosystem**"));
1559        assert!(out.contains("Edge Changes"));
1560        assert!(out.contains("**Removed dependencies:**"));
1561        assert!(out.contains("**Added dependencies:**"));
1562    }
1563
1564    #[test]
1565    fn test_markdown_renderer_empty_diff() {
1566        let diff = mock_diff_empty();
1567        let mut buf = Vec::new();
1568        MarkdownRenderer
1569            .render(&diff, &RenderOptions::default(), &mut buf)
1570            .unwrap();
1571        let out = String::from_utf8(buf).unwrap();
1572
1573        assert!(out.contains("| Added | 0 |"));
1574        assert!(!out.contains("<details>"));
1575    }
1576
1577    #[test]
1578    fn test_json_renderer() {
1579        let diff = mock_diff();
1580        let mut buf = Vec::new();
1581        JsonRenderer
1582            .render(&diff, &RenderOptions::default(), &mut buf)
1583            .unwrap();
1584        let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1585    }
1586
1587    #[test]
1588    fn test_json_renderer_all_field_changes() {
1589        let diff = mock_diff_all_field_changes();
1590        let mut buf = Vec::new();
1591        JsonRenderer
1592            .render(&diff, &RenderOptions::default(), &mut buf)
1593            .unwrap();
1594        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1595
1596        assert_eq!(val["changed"].as_array().unwrap().len(), 1);
1597        assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 7);
1598        assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
1599    }
1600
1601    #[test]
1602    fn test_json_renderer_roundtrip() {
1603        let diff = mock_diff_all_field_changes();
1604        let mut buf = Vec::new();
1605        JsonRenderer
1606            .render(&diff, &RenderOptions::default(), &mut buf)
1607            .unwrap();
1608
1609        let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
1610        assert_eq!(deserialized.changed.len(), diff.changed.len());
1611        assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
1612        assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
1613    }
1614
1615    fn mock_diff_with_ecosystems() -> Diff {
1616        let mut added_npm = Component::new("express".into(), Some("4.18.0".into()));
1617        added_npm.ecosystem = Some("npm".into());
1618        let mut added_cargo = Component::new("serde".into(), Some("1.0.0".into()));
1619        added_cargo.ecosystem = Some("cargo".into());
1620
1621        let mut removed = Component::new("lodash".into(), Some("4.17.21".into()));
1622        removed.ecosystem = Some("npm".into());
1623
1624        let mut old = Component::new("react".into(), Some("17.0.0".into()));
1625        old.ecosystem = Some("npm".into());
1626        let mut new = old.clone();
1627        new.version = Some("18.0.0".into());
1628
1629        Diff {
1630            added: vec![added_npm, added_cargo],
1631            removed: vec![removed],
1632            changed: vec![ComponentChange {
1633                id: new.id.clone(),
1634                old,
1635                new,
1636                changes: vec![FieldChange::Version(
1637                    Some("17.0.0".into()),
1638                    Some("18.0.0".into()),
1639                )],
1640            }],
1641            edge_diffs: vec![],
1642            ..Diff::default()
1643        }
1644    }
1645
1646    #[test]
1647    fn test_text_renderer_group_by_ecosystem() {
1648        let diff = mock_diff_with_ecosystems();
1649        let opts = RenderOptions {
1650            group_by_ecosystem: true,
1651            ..Default::default()
1652        };
1653        let mut buf = Vec::new();
1654        TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1655        let out = String::from_utf8(buf).unwrap();
1656
1657        assert!(out.contains("By Ecosystem"));
1658        assert!(out.contains("cargo: 1 added, 0 removed, 0 changed"));
1659        assert!(out.contains("npm: 1 added, 1 removed, 1 changed"));
1660    }
1661
1662    #[test]
1663    fn test_text_renderer_no_ecosystem_by_default() {
1664        let diff = mock_diff_with_ecosystems();
1665        let mut buf = Vec::new();
1666        TextRenderer
1667            .render(&diff, &RenderOptions::default(), &mut buf)
1668            .unwrap();
1669        let out = String::from_utf8(buf).unwrap();
1670
1671        assert!(!out.contains("By Ecosystem"));
1672    }
1673
1674    #[test]
1675    fn test_markdown_renderer_group_by_ecosystem() {
1676        let diff = mock_diff_with_ecosystems();
1677        let opts = RenderOptions {
1678            group_by_ecosystem: true,
1679            ..Default::default()
1680        };
1681        let mut buf = Vec::new();
1682        MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1683        let out = String::from_utf8(buf).unwrap();
1684
1685        assert!(out.contains("#### By Ecosystem"));
1686        assert!(out.contains("| Ecosystem | Added | Removed | Changed |"));
1687        assert!(out.contains("| cargo | 1 | 0 | 0 |"));
1688        assert!(out.contains("| npm | 1 | 1 | 1 |"));
1689    }
1690
1691    #[test]
1692    fn test_json_renderer_group_by_ecosystem() {
1693        let diff = mock_diff_with_ecosystems();
1694        let opts = RenderOptions {
1695            group_by_ecosystem: true,
1696            ..Default::default()
1697        };
1698        let mut buf = Vec::new();
1699        JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1700        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1701
1702        let breakdown = &val["ecosystem_breakdown"];
1703        assert!(breakdown.is_object());
1704        assert_eq!(breakdown["npm"]["added"], 1);
1705        assert_eq!(breakdown["npm"]["removed"], 1);
1706        assert_eq!(breakdown["npm"]["changed"], 1);
1707        assert_eq!(breakdown["cargo"]["added"], 1);
1708        assert_eq!(breakdown["cargo"]["removed"], 0);
1709    }
1710
1711    #[test]
1712    fn test_json_renderer_no_ecosystem_by_default() {
1713        let diff = mock_diff_with_ecosystems();
1714        let mut buf = Vec::new();
1715        JsonRenderer
1716            .render(&diff, &RenderOptions::default(), &mut buf)
1717            .unwrap();
1718        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1719
1720        assert!(val.get("ecosystem_breakdown").is_none());
1721    }
1722
1723    fn opts_with_warnings() -> RenderOptions {
1724        RenderOptions {
1725            show_warnings: true,
1726            old_warnings: vec!["SPDX: orphaned ref 'SPDXRef-foo'".into()],
1727            new_warnings: vec!["CycloneDX: unknown bom-ref 'bar'".into()],
1728            ..Default::default()
1729        }
1730    }
1731
1732    #[test]
1733    fn test_text_renderer_shows_warnings() {
1734        let diff = mock_diff();
1735        let opts = opts_with_warnings();
1736        let mut buf = Vec::new();
1737        TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1738        let out = String::from_utf8(buf).unwrap();
1739
1740        assert!(out.contains("[!] Warnings"));
1741        assert!(out.contains("[old] SPDX: orphaned ref 'SPDXRef-foo'"));
1742        assert!(out.contains("[new] CycloneDX: unknown bom-ref 'bar'"));
1743    }
1744
1745    #[test]
1746    fn test_text_renderer_hides_warnings_by_default() {
1747        let diff = mock_diff();
1748        let mut buf = Vec::new();
1749        TextRenderer
1750            .render(&diff, &RenderOptions::default(), &mut buf)
1751            .unwrap();
1752        let out = String::from_utf8(buf).unwrap();
1753
1754        assert!(!out.contains("[!] Warnings"));
1755    }
1756
1757    #[test]
1758    fn test_markdown_renderer_shows_warnings() {
1759        let diff = mock_diff();
1760        let opts = opts_with_warnings();
1761        let mut buf = Vec::new();
1762        MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1763        let out = String::from_utf8(buf).unwrap();
1764
1765        assert!(out.contains("<details><summary><b>Warnings (2)</b></summary>"));
1766        assert!(out.contains("- **old:** SPDX: orphaned ref 'SPDXRef-foo'"));
1767        assert!(out.contains("- **new:** CycloneDX: unknown bom-ref 'bar'"));
1768    }
1769
1770    #[test]
1771    fn test_markdown_renderer_hides_warnings_by_default() {
1772        let diff = mock_diff();
1773        let mut buf = Vec::new();
1774        MarkdownRenderer
1775            .render(&diff, &RenderOptions::default(), &mut buf)
1776            .unwrap();
1777        let out = String::from_utf8(buf).unwrap();
1778
1779        assert!(!out.contains("Warnings"));
1780    }
1781
1782    #[test]
1783    fn test_json_renderer_shows_warnings() {
1784        let diff = mock_diff();
1785        let opts = opts_with_warnings();
1786        let mut buf = Vec::new();
1787        JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1788        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1789
1790        let warnings = &val["warnings"];
1791        let old = warnings["old"].as_array().unwrap();
1792        let new = warnings["new"].as_array().unwrap();
1793        assert_eq!(old.len(), 1);
1794        assert_eq!(new.len(), 1);
1795        assert_eq!(old[0], "SPDX: orphaned ref 'SPDXRef-foo'");
1796        assert_eq!(new[0], "CycloneDX: unknown bom-ref 'bar'");
1797    }
1798
1799    #[test]
1800    fn test_json_renderer_hides_warnings_by_default() {
1801        let diff = mock_diff();
1802        let mut buf = Vec::new();
1803        JsonRenderer
1804            .render(&diff, &RenderOptions::default(), &mut buf)
1805            .unwrap();
1806        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1807
1808        assert!(val.get("warnings").is_none());
1809    }
1810
1811    #[test]
1812    fn test_empty_warnings_not_shown() {
1813        let diff = mock_diff();
1814        let opts = RenderOptions {
1815            show_warnings: true,
1816            ..Default::default()
1817        };
1818
1819        let mut buf = Vec::new();
1820        TextRenderer.render(&diff, &opts, &mut buf).unwrap();
1821        let out = String::from_utf8(buf).unwrap();
1822        assert!(!out.contains("[!] Warnings"));
1823
1824        let mut buf = Vec::new();
1825        MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
1826        let out = String::from_utf8(buf).unwrap();
1827        assert!(!out.contains("Warnings"));
1828
1829        let mut buf = Vec::new();
1830        JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
1831        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1832        assert!(val.get("warnings").is_none());
1833    }
1834
1835    fn mock_diff_with_hash_edge_diffs() -> Diff {
1836        use sbom_model::{ComponentId, DependencyKind};
1837
1838        let parent_id = ComponentId::new(None, &[("name", "parent")]);
1839        let child_a_id = ComponentId::new(None, &[("name", "child-a")]);
1840        let child_b_id = ComponentId::new(None, &[("name", "child-b")]);
1841
1842        let mut names = BTreeMap::new();
1843        names.insert(parent_id.clone(), "my-app@1.0".to_string());
1844        names.insert(child_a_id.clone(), "old-dep@0.1".to_string());
1845        names.insert(child_b_id.clone(), "new-dep@0.2".to_string());
1846
1847        Diff {
1848            edge_diffs: vec![crate::EdgeDiff {
1849                parent: parent_id,
1850                added: BTreeMap::from([(child_b_id, DependencyKind::Runtime)]),
1851                removed: BTreeMap::from([(child_a_id, DependencyKind::Runtime)]),
1852                kind_changed: BTreeMap::new(),
1853            }],
1854            old_total: 10,
1855            new_total: 12,
1856            unchanged: 5,
1857            component_names: names,
1858            ..Diff::default()
1859        }
1860    }
1861
1862    #[test]
1863    fn test_text_renderer_resolves_edge_diff_names() {
1864        let diff = mock_diff_with_hash_edge_diffs();
1865        let mut buf = Vec::new();
1866        TextRenderer
1867            .render(&diff, &RenderOptions::default(), &mut buf)
1868            .unwrap();
1869        let out = String::from_utf8(buf).unwrap();
1870
1871        assert!(out.contains("my-app@1.0"));
1872        assert!(out.contains("- old-dep@0.1"));
1873        assert!(out.contains("+ new-dep@0.2"));
1874        // Should NOT contain raw hash IDs
1875        assert!(!out.contains("h:"));
1876    }
1877
1878    #[test]
1879    fn test_text_renderer_shows_totals() {
1880        let diff = mock_diff_with_hash_edge_diffs();
1881        let mut buf = Vec::new();
1882        TextRenderer
1883            .render(&diff, &RenderOptions::default(), &mut buf)
1884            .unwrap();
1885        let out = String::from_utf8(buf).unwrap();
1886
1887        assert!(out.contains("Old total:        10 components"));
1888        assert!(out.contains("New total:        12 components"));
1889        assert!(out.contains("Unchanged:        5"));
1890    }
1891
1892    #[test]
1893    fn test_markdown_renderer_resolves_edge_diff_names() {
1894        let diff = mock_diff_with_hash_edge_diffs();
1895        let mut buf = Vec::new();
1896        MarkdownRenderer
1897            .render(&diff, &RenderOptions::default(), &mut buf)
1898            .unwrap();
1899        let out = String::from_utf8(buf).unwrap();
1900
1901        assert!(out.contains("`my-app@1.0`"));
1902        assert!(out.contains("`old-dep@0.1`"));
1903        assert!(out.contains("`new-dep@0.2`"));
1904        assert!(!out.contains("h:"));
1905    }
1906
1907    #[test]
1908    fn test_markdown_renderer_shows_totals() {
1909        let diff = mock_diff_with_hash_edge_diffs();
1910        let mut buf = Vec::new();
1911        MarkdownRenderer
1912            .render(&diff, &RenderOptions::default(), &mut buf)
1913            .unwrap();
1914        let out = String::from_utf8(buf).unwrap();
1915
1916        assert!(out.contains("| Old total | 10 |"));
1917        assert!(out.contains("| New total | 12 |"));
1918        assert!(out.contains("| Unchanged | 5 |"));
1919    }
1920
1921    #[test]
1922    fn test_json_renderer_includes_totals() {
1923        let diff = mock_diff_with_hash_edge_diffs();
1924        let mut buf = Vec::new();
1925        JsonRenderer
1926            .render(&diff, &RenderOptions::default(), &mut buf)
1927            .unwrap();
1928        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1929
1930        assert_eq!(val["old_total"], 10);
1931        assert_eq!(val["new_total"], 12);
1932        assert_eq!(val["unchanged"], 5);
1933    }
1934
1935    #[test]
1936    fn test_json_renderer_includes_component_names() {
1937        let diff = mock_diff_with_hash_edge_diffs();
1938        let mut buf = Vec::new();
1939        JsonRenderer
1940            .render(&diff, &RenderOptions::default(), &mut buf)
1941            .unwrap();
1942        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1943
1944        let names = &val["component_names"];
1945        assert!(names.is_object());
1946        assert!(names
1947            .as_object()
1948            .unwrap()
1949            .values()
1950            .any(|v| v == "my-app@1.0"));
1951    }
1952
1953    #[test]
1954    fn test_json_renderer_omits_empty_component_names() {
1955        let diff = mock_diff();
1956        let mut buf = Vec::new();
1957        JsonRenderer
1958            .render(&diff, &RenderOptions::default(), &mut buf)
1959            .unwrap();
1960        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1961
1962        assert!(val.get("component_names").is_none());
1963    }
1964
1965    fn mock_diff_with_metadata_change() -> Diff {
1966        Diff {
1967            metadata_changed: Some(crate::MetadataChange {
1968                timestamp: Some((Some("2024-01-01".into()), Some("2024-01-02".into()))),
1969                tools: Some((vec!["syft".into()], vec!["trivy".into()])),
1970                authors: None,
1971            }),
1972            ..Diff::default()
1973        }
1974    }
1975
1976    #[test]
1977    fn test_text_renderer_metadata_change() {
1978        let diff = mock_diff_with_metadata_change();
1979        let mut buf = Vec::new();
1980        TextRenderer
1981            .render(&diff, &RenderOptions::default(), &mut buf)
1982            .unwrap();
1983        let out = String::from_utf8(buf).unwrap();
1984
1985        assert!(out.contains("[~] Metadata Changes"));
1986        assert!(out.contains("Timestamp: 2024-01-01 -> 2024-01-02"));
1987        assert!(out.contains("Tools: syft -> trivy"));
1988        // Authors not changed, should not appear
1989        assert!(!out.contains("Authors:"));
1990    }
1991
1992    #[test]
1993    fn test_text_renderer_no_metadata_section_when_unchanged() {
1994        let diff = mock_diff_empty();
1995        let mut buf = Vec::new();
1996        TextRenderer
1997            .render(&diff, &RenderOptions::default(), &mut buf)
1998            .unwrap();
1999        let out = String::from_utf8(buf).unwrap();
2000
2001        assert!(!out.contains("Metadata Changes"));
2002    }
2003
2004    #[test]
2005    fn test_markdown_renderer_metadata_change() {
2006        let diff = mock_diff_with_metadata_change();
2007        let mut buf = Vec::new();
2008        MarkdownRenderer
2009            .render(&diff, &RenderOptions::default(), &mut buf)
2010            .unwrap();
2011        let out = String::from_utf8(buf).unwrap();
2012
2013        assert!(out.contains("<details><summary><b>Metadata Changes</b></summary>"));
2014        assert!(out.contains("**Timestamp**"));
2015        assert!(out.contains("`2024-01-01` &rarr; `2024-01-02`"));
2016        assert!(out.contains("**Tools**"));
2017        assert!(out.contains("`syft` &rarr; `trivy`"));
2018        assert!(!out.contains("**Authors**"));
2019        assert!(out.contains("</details>"));
2020    }
2021
2022    #[test]
2023    fn test_markdown_renderer_no_metadata_section_when_unchanged() {
2024        let diff = mock_diff_empty();
2025        let mut buf = Vec::new();
2026        MarkdownRenderer
2027            .render(&diff, &RenderOptions::default(), &mut buf)
2028            .unwrap();
2029        let out = String::from_utf8(buf).unwrap();
2030
2031        assert!(!out.contains("Metadata Changes"));
2032    }
2033
2034    #[test]
2035    fn test_json_renderer_metadata_change() {
2036        let diff = mock_diff_with_metadata_change();
2037        let mut buf = Vec::new();
2038        JsonRenderer
2039            .render(&diff, &RenderOptions::default(), &mut buf)
2040            .unwrap();
2041        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2042
2043        let mc = &val["metadata_changed"];
2044        assert!(mc.is_object());
2045        let ts = mc["timestamp"].as_array().unwrap();
2046        assert_eq!(ts[0], "2024-01-01");
2047        assert_eq!(ts[1], "2024-01-02");
2048        let tools = mc["tools"].as_array().unwrap();
2049        assert_eq!(tools[0], serde_json::json!(["syft"]));
2050        assert_eq!(tools[1], serde_json::json!(["trivy"]));
2051        // authors should be absent (skip_serializing_if)
2052        assert!(mc.get("authors").is_none());
2053    }
2054
2055    #[test]
2056    fn test_json_renderer_no_metadata_when_unchanged() {
2057        let diff = mock_diff_empty();
2058        let mut buf = Vec::new();
2059        JsonRenderer
2060            .render(&diff, &RenderOptions::default(), &mut buf)
2061            .unwrap();
2062        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2063
2064        assert!(val.get("metadata_changed").is_none());
2065    }
2066
2067    #[test]
2068    fn test_text_summary_metadata_changed() {
2069        let diff = mock_diff_with_metadata_change();
2070        let mut buf = Vec::new();
2071        TextRenderer
2072            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2073            .unwrap();
2074        let out = String::from_utf8(buf).unwrap();
2075
2076        assert!(out.contains("Metadata changed: yes"));
2077    }
2078
2079    #[test]
2080    fn test_text_summary_metadata_unchanged() {
2081        let diff = mock_diff_empty();
2082        let mut buf = Vec::new();
2083        TextRenderer
2084            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2085            .unwrap();
2086        let out = String::from_utf8(buf).unwrap();
2087
2088        assert!(out.contains("Metadata changed: no"));
2089    }
2090
2091    #[test]
2092    fn test_markdown_summary_metadata_changed() {
2093        let diff = mock_diff_with_metadata_change();
2094        let mut buf = Vec::new();
2095        MarkdownRenderer
2096            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2097            .unwrap();
2098        let out = String::from_utf8(buf).unwrap();
2099
2100        assert!(out.contains("| Metadata changed | yes |"));
2101    }
2102
2103    #[test]
2104    fn test_markdown_summary_metadata_unchanged() {
2105        let diff = mock_diff_empty();
2106        let mut buf = Vec::new();
2107        MarkdownRenderer
2108            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2109            .unwrap();
2110        let out = String::from_utf8(buf).unwrap();
2111
2112        assert!(out.contains("| Metadata changed | no |"));
2113    }
2114
2115    #[test]
2116    fn test_json_summary_metadata_changed() {
2117        let diff = mock_diff_with_metadata_change();
2118        let mut buf = Vec::new();
2119        JsonRenderer
2120            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2121            .unwrap();
2122        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2123
2124        assert_eq!(val["metadata_changed"], true);
2125        let mc = &val["metadata_changes"];
2126        assert!(mc.is_object());
2127        assert!(mc["timestamp"].is_array());
2128    }
2129
2130    #[test]
2131    fn test_json_summary_metadata_unchanged() {
2132        let diff = mock_diff_empty();
2133        let mut buf = Vec::new();
2134        JsonRenderer
2135            .render_summary(&diff, &RenderOptions::default(), &mut buf)
2136            .unwrap();
2137        let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
2138
2139        assert_eq!(val["metadata_changed"], false);
2140        assert!(val.get("metadata_changes").is_none());
2141    }
2142
2143    // --- SARIF renderer tests ---
2144
2145    fn sarif_parse(buf: &[u8]) -> serde_json::Value {
2146        serde_json::from_slice(buf).unwrap()
2147    }
2148
2149    #[test]
2150    fn test_sarif_renderer_schema_and_version() {
2151        let diff = mock_diff();
2152        let mut buf = Vec::new();
2153        SarifRenderer
2154            .render(&diff, &RenderOptions::default(), &mut buf)
2155            .unwrap();
2156        let val = sarif_parse(&buf);
2157
2158        assert_eq!(
2159            val["$schema"],
2160            "https://json.schemastore.org/sarif-2.1.0.json"
2161        );
2162        assert_eq!(val["version"], "2.1.0");
2163        assert!(val["runs"].is_array());
2164        assert_eq!(val["runs"].as_array().unwrap().len(), 1);
2165    }
2166
2167    #[test]
2168    fn test_sarif_renderer_tool_driver() {
2169        let diff = mock_diff_empty();
2170        let mut buf = Vec::new();
2171        SarifRenderer
2172            .render(&diff, &RenderOptions::default(), &mut buf)
2173            .unwrap();
2174        let val = sarif_parse(&buf);
2175
2176        let driver = &val["runs"][0]["tool"]["driver"];
2177        assert_eq!(driver["name"], "sbom-diff");
2178        assert!(driver["version"].is_string());
2179        assert_eq!(
2180            driver["informationUri"],
2181            "https://github.com/cyberwitchery/sbom-diff"
2182        );
2183    }
2184
2185    #[test]
2186    fn test_sarif_renderer_rules() {
2187        let diff = mock_diff_empty();
2188        let mut buf = Vec::new();
2189        SarifRenderer
2190            .render(&diff, &RenderOptions::default(), &mut buf)
2191            .unwrap();
2192        let val = sarif_parse(&buf);
2193
2194        let rules = val["runs"][0]["tool"]["driver"]["rules"]
2195            .as_array()
2196            .unwrap();
2197        assert_eq!(rules.len(), 6);
2198
2199        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2200        assert_eq!(
2201            rule_ids,
2202            vec![
2203                "component-added",
2204                "component-removed",
2205                "component-changed",
2206                "dependency-changed",
2207                "metadata-changed",
2208                "parser-warning",
2209            ]
2210        );
2211
2212        // Check that each rule has required fields
2213        for rule in rules {
2214            assert!(rule["shortDescription"]["text"].is_string());
2215            assert!(rule["fullDescription"]["text"].is_string());
2216            assert!(rule["defaultConfiguration"]["level"].is_string());
2217        }
2218    }
2219
2220    #[test]
2221    fn test_sarif_renderer_empty_diff() {
2222        let diff = mock_diff_empty();
2223        let mut buf = Vec::new();
2224        SarifRenderer
2225            .render(&diff, &RenderOptions::default(), &mut buf)
2226            .unwrap();
2227        let val = sarif_parse(&buf);
2228
2229        let results = val["runs"][0]["results"].as_array().unwrap();
2230        assert!(results.is_empty());
2231    }
2232
2233    #[test]
2234    fn test_sarif_renderer_added_removed_changed() {
2235        let diff = mock_diff();
2236        let mut buf = Vec::new();
2237        SarifRenderer
2238            .render(&diff, &RenderOptions::default(), &mut buf)
2239            .unwrap();
2240        let val = sarif_parse(&buf);
2241
2242        let results = val["runs"][0]["results"].as_array().unwrap();
2243        assert_eq!(results.len(), 3); // 1 added + 1 removed + 1 changed
2244
2245        // Check rule IDs
2246        let rule_ids: Vec<&str> = results
2247            .iter()
2248            .map(|r| r["ruleId"].as_str().unwrap())
2249            .collect();
2250        assert!(rule_ids.contains(&"component-added"));
2251        assert!(rule_ids.contains(&"component-removed"));
2252        assert!(rule_ids.contains(&"component-changed"));
2253
2254        // Added component is note level
2255        let added = results
2256            .iter()
2257            .find(|r| r["ruleId"] == "component-added")
2258            .unwrap();
2259        assert_eq!(added["level"], "note");
2260        assert!(added["message"]["text"].as_str().unwrap().contains("added"));
2261
2262        // Removed component is warning level
2263        let removed = results
2264            .iter()
2265            .find(|r| r["ruleId"] == "component-removed")
2266            .unwrap();
2267        assert_eq!(removed["level"], "warning");
2268
2269        // Changed component is warning level
2270        let changed = results
2271            .iter()
2272            .find(|r| r["ruleId"] == "component-changed")
2273            .unwrap();
2274        assert_eq!(changed["level"], "warning");
2275        let msg = changed["message"]["text"].as_str().unwrap();
2276        assert!(msg.contains("version:"));
2277    }
2278
2279    #[test]
2280    fn test_sarif_renderer_all_field_changes() {
2281        let diff = mock_diff_all_field_changes();
2282        let mut buf = Vec::new();
2283        SarifRenderer
2284            .render(&diff, &RenderOptions::default(), &mut buf)
2285            .unwrap();
2286        let val = sarif_parse(&buf);
2287
2288        let results = val["runs"][0]["results"].as_array().unwrap();
2289
2290        // 1 changed component + 1 dependency-changed edge diff
2291        let changed = results
2292            .iter()
2293            .find(|r| r["ruleId"] == "component-changed")
2294            .unwrap();
2295        let msg = changed["message"]["text"].as_str().unwrap();
2296        assert!(msg.contains("version:"));
2297        assert!(msg.contains("license:"));
2298        assert!(msg.contains("supplier:"));
2299        assert!(msg.contains("purl:"));
2300        assert!(msg.contains("description:"));
2301        assert!(msg.contains("hashes changed"));
2302        assert!(msg.contains("ecosystem:"));
2303
2304        let dep = results
2305            .iter()
2306            .find(|r| r["ruleId"] == "dependency-changed")
2307            .unwrap();
2308        assert_eq!(dep["level"], "note");
2309        let dep_msg = dep["message"]["text"].as_str().unwrap();
2310        assert!(dep_msg.contains("Dependency changed:"));
2311    }
2312
2313    #[test]
2314    fn test_sarif_renderer_rule_index() {
2315        let diff = mock_diff();
2316        let mut buf = Vec::new();
2317        SarifRenderer
2318            .render(&diff, &RenderOptions::default(), &mut buf)
2319            .unwrap();
2320        let val = sarif_parse(&buf);
2321
2322        let results = val["runs"][0]["results"].as_array().unwrap();
2323
2324        // Each result's ruleIndex should match its ruleId position in rules array
2325        for result in results {
2326            let rule_id = result["ruleId"].as_str().unwrap();
2327            let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
2328            let rules = val["runs"][0]["tool"]["driver"]["rules"]
2329                .as_array()
2330                .unwrap();
2331            assert_eq!(rules[rule_index]["id"].as_str().unwrap(), rule_id);
2332        }
2333    }
2334
2335    #[test]
2336    fn test_sarif_renderer_metadata_change() {
2337        let diff = mock_diff_with_metadata_change();
2338        let mut buf = Vec::new();
2339        SarifRenderer
2340            .render(&diff, &RenderOptions::default(), &mut buf)
2341            .unwrap();
2342        let val = sarif_parse(&buf);
2343
2344        let results = val["runs"][0]["results"].as_array().unwrap();
2345        let meta = results
2346            .iter()
2347            .find(|r| r["ruleId"] == "metadata-changed")
2348            .unwrap();
2349        assert_eq!(meta["level"], "note");
2350        let msg = meta["message"]["text"].as_str().unwrap();
2351        assert!(msg.contains("timestamp:"));
2352        assert!(msg.contains("tools:"));
2353    }
2354
2355    #[test]
2356    fn test_sarif_renderer_no_metadata_when_unchanged() {
2357        let diff = mock_diff_empty();
2358        let mut buf = Vec::new();
2359        SarifRenderer
2360            .render(&diff, &RenderOptions::default(), &mut buf)
2361            .unwrap();
2362        let val = sarif_parse(&buf);
2363
2364        let results = val["runs"][0]["results"].as_array().unwrap();
2365        assert!(!results.iter().any(|r| r["ruleId"] == "metadata-changed"));
2366    }
2367
2368    #[test]
2369    fn test_sarif_renderer_summary_same_as_full() {
2370        let diff = mock_diff();
2371        let opts = RenderOptions::default();
2372
2373        let mut buf_full = Vec::new();
2374        SarifRenderer.render(&diff, &opts, &mut buf_full).unwrap();
2375
2376        let mut buf_summary = Vec::new();
2377        SarifRenderer
2378            .render_summary(&diff, &opts, &mut buf_summary)
2379            .unwrap();
2380
2381        assert_eq!(buf_full, buf_summary);
2382    }
2383
2384    #[test]
2385    fn test_sarif_renderer_edge_diffs_with_names() {
2386        let diff = mock_diff_with_hash_edge_diffs();
2387        let mut buf = Vec::new();
2388        SarifRenderer
2389            .render(&diff, &RenderOptions::default(), &mut buf)
2390            .unwrap();
2391        let val = sarif_parse(&buf);
2392
2393        let results = val["runs"][0]["results"].as_array().unwrap();
2394        let dep = results
2395            .iter()
2396            .find(|r| r["ruleId"] == "dependency-changed")
2397            .unwrap();
2398        let msg = dep["message"]["text"].as_str().unwrap();
2399        // Should use resolved display names, not raw hash IDs
2400        assert!(msg.contains("my-app@1.0"));
2401        assert!(msg.contains("old-dep@0.1"));
2402        assert!(msg.contains("new-dep@0.2"));
2403    }
2404
2405    #[test]
2406    fn test_sarif_renderer_locations_present_and_well_formed() {
2407        // Component results: added, removed, changed all get "package" locations
2408        let diff = mock_diff();
2409        let mut buf = Vec::new();
2410        SarifRenderer
2411            .render(&diff, &RenderOptions::default(), &mut buf)
2412            .unwrap();
2413        let val = sarif_parse(&buf);
2414        let results = val["runs"][0]["results"].as_array().unwrap();
2415
2416        for rule_id in ["component-added", "component-removed", "component-changed"] {
2417            let result = results
2418                .iter()
2419                .find(|r| r["ruleId"] == rule_id)
2420                .unwrap_or_else(|| panic!("missing result for {rule_id}"));
2421            let locs = result["locations"]
2422                .as_array()
2423                .unwrap_or_else(|| panic!("{rule_id}: locations missing"));
2424            assert_eq!(locs.len(), 1, "{rule_id}: expected 1 location");
2425            let ll = locs[0]["logicalLocations"]
2426                .as_array()
2427                .unwrap_or_else(|| panic!("{rule_id}: logicalLocations missing"));
2428            assert_eq!(ll.len(), 1, "{rule_id}: expected 1 logicalLocation");
2429            assert!(
2430                !ll[0]["fullyQualifiedName"].as_str().unwrap().is_empty(),
2431                "{rule_id}: fullyQualifiedName should be non-empty"
2432            );
2433            assert_eq!(
2434                ll[0]["kind"], "package",
2435                "{rule_id}: kind should be package"
2436            );
2437        }
2438
2439        // Dependency result: uses parent display name
2440        let diff = mock_diff_with_hash_edge_diffs();
2441        let mut buf = Vec::new();
2442        SarifRenderer
2443            .render(&diff, &RenderOptions::default(), &mut buf)
2444            .unwrap();
2445        let val = sarif_parse(&buf);
2446        let results = val["runs"][0]["results"].as_array().unwrap();
2447
2448        let dep = results
2449            .iter()
2450            .find(|r| r["ruleId"] == "dependency-changed")
2451            .unwrap();
2452        let locs = dep["locations"].as_array().unwrap();
2453        assert_eq!(locs.len(), 1);
2454        let ll = locs[0]["logicalLocations"].as_array().unwrap();
2455        assert_eq!(ll[0]["fullyQualifiedName"], "my-app@1.0");
2456        assert_eq!(ll[0]["kind"], "package");
2457
2458        // Metadata result: uses "metadata" with kind "module"
2459        let diff = mock_diff_with_metadata_change();
2460        let mut buf = Vec::new();
2461        SarifRenderer
2462            .render(&diff, &RenderOptions::default(), &mut buf)
2463            .unwrap();
2464        let val = sarif_parse(&buf);
2465        let results = val["runs"][0]["results"].as_array().unwrap();
2466
2467        let meta = results
2468            .iter()
2469            .find(|r| r["ruleId"] == "metadata-changed")
2470            .unwrap();
2471        let locs = meta["locations"].as_array().unwrap();
2472        assert_eq!(locs.len(), 1);
2473        let ll = locs[0]["logicalLocations"].as_array().unwrap();
2474        assert_eq!(ll[0]["fullyQualifiedName"], "metadata");
2475        assert_eq!(ll[0]["kind"], "module");
2476    }
2477
2478    #[test]
2479    fn test_sarif_renderer_shows_warnings() {
2480        let diff = mock_diff();
2481        let opts = opts_with_warnings();
2482        let mut buf = Vec::new();
2483        SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2484        let val = sarif_parse(&buf);
2485
2486        let results = val["runs"][0]["results"].as_array().unwrap();
2487        let warnings: Vec<_> = results
2488            .iter()
2489            .filter(|r| r["ruleId"] == "parser-warning")
2490            .collect();
2491        assert_eq!(warnings.len(), 2);
2492
2493        // All warnings are note level
2494        for w in &warnings {
2495            assert_eq!(w["level"], "note");
2496        }
2497
2498        // Check old-SBOM warning
2499        let old_warning = warnings
2500            .iter()
2501            .find(|w| w["message"]["text"].as_str().unwrap().contains("old SBOM"))
2502            .expect("should have old SBOM warning");
2503        assert!(old_warning["message"]["text"]
2504            .as_str()
2505            .unwrap()
2506            .contains("orphaned ref 'SPDXRef-foo'"));
2507        let ll = old_warning["locations"][0]["logicalLocations"]
2508            .as_array()
2509            .unwrap();
2510        assert_eq!(ll[0]["fullyQualifiedName"], "old-sbom");
2511        assert_eq!(ll[0]["kind"], "module");
2512
2513        // Check new-SBOM warning
2514        let new_warning = warnings
2515            .iter()
2516            .find(|w| w["message"]["text"].as_str().unwrap().contains("new SBOM"))
2517            .expect("should have new SBOM warning");
2518        assert!(new_warning["message"]["text"]
2519            .as_str()
2520            .unwrap()
2521            .contains("unknown bom-ref 'bar'"));
2522        let ll = new_warning["locations"][0]["logicalLocations"]
2523            .as_array()
2524            .unwrap();
2525        assert_eq!(ll[0]["fullyQualifiedName"], "new-sbom");
2526        assert_eq!(ll[0]["kind"], "module");
2527    }
2528
2529    #[test]
2530    fn test_sarif_renderer_hides_warnings_by_default() {
2531        let diff = mock_diff();
2532        let mut buf = Vec::new();
2533        SarifRenderer
2534            .render(&diff, &RenderOptions::default(), &mut buf)
2535            .unwrap();
2536        let val = sarif_parse(&buf);
2537
2538        let results = val["runs"][0]["results"].as_array().unwrap();
2539        assert!(
2540            !results.iter().any(|r| r["ruleId"] == "parser-warning"),
2541            "should not emit parser-warning results without show_warnings"
2542        );
2543    }
2544
2545    #[test]
2546    fn test_sarif_renderer_no_warnings_when_empty() {
2547        let diff = mock_diff();
2548        let opts = RenderOptions {
2549            show_warnings: true,
2550            ..Default::default()
2551        };
2552        let mut buf = Vec::new();
2553        SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2554        let val = sarif_parse(&buf);
2555
2556        let results = val["runs"][0]["results"].as_array().unwrap();
2557        assert!(
2558            !results.iter().any(|r| r["ruleId"] == "parser-warning"),
2559            "should not emit parser-warning results when warning lists are empty"
2560        );
2561    }
2562
2563    #[test]
2564    fn test_sarif_renderer_warnings_rule_index() {
2565        let diff = mock_diff_empty();
2566        let opts = opts_with_warnings();
2567        let mut buf = Vec::new();
2568        SarifRenderer.render(&diff, &opts, &mut buf).unwrap();
2569        let val = sarif_parse(&buf);
2570
2571        let rules = val["runs"][0]["tool"]["driver"]["rules"]
2572            .as_array()
2573            .unwrap();
2574        let results = val["runs"][0]["results"].as_array().unwrap();
2575
2576        for result in results.iter().filter(|r| r["ruleId"] == "parser-warning") {
2577            let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
2578            assert_eq!(rules[rule_index]["id"], "parser-warning");
2579        }
2580    }
2581
2582    #[test]
2583    fn test_sarif_renderer_warnings_rule_present() {
2584        let diff = mock_diff_empty();
2585        let mut buf = Vec::new();
2586        SarifRenderer
2587            .render(&diff, &RenderOptions::default(), &mut buf)
2588            .unwrap();
2589        let val = sarif_parse(&buf);
2590
2591        let rules = val["runs"][0]["tool"]["driver"]["rules"]
2592            .as_array()
2593            .unwrap();
2594        assert_eq!(rules.len(), 6);
2595        assert_eq!(rules[5]["id"], "parser-warning");
2596        assert_eq!(rules[5]["defaultConfiguration"]["level"], "note");
2597    }
2598
2599    #[test]
2600    fn test_sarif_renderer_no_metadata_when_all_none_subfields() {
2601        let diff = Diff {
2602            metadata_changed: Some(crate::MetadataChange {
2603                timestamp: None,
2604                tools: None,
2605                authors: None,
2606            }),
2607            ..Diff::default()
2608        };
2609        let mut buf = Vec::new();
2610        SarifRenderer
2611            .render(&diff, &RenderOptions::default(), &mut buf)
2612            .unwrap();
2613        let val = sarif_parse(&buf);
2614
2615        let results = val["runs"][0]["results"].as_array().unwrap();
2616        assert!(
2617            !results.iter().any(|r| r["ruleId"] == "metadata-changed"),
2618            "MetadataChange with all-None subfields should not emit a result"
2619        );
2620    }
2621}