use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange, GroupedDiff};
use sbom_model::Component;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub group_by_ecosystem: bool,
pub show_warnings: bool,
pub old_warnings: Vec<String>,
pub new_warnings: Vec<String>,
}
impl RenderOptions {
pub fn has_warnings(&self) -> bool {
self.show_warnings && (!self.old_warnings.is_empty() || !self.new_warnings.is_empty())
}
pub fn warning_count(&self) -> usize {
self.old_warnings.len() + self.new_warnings.len()
}
}
fn format_option(opt: &Option<String>) -> &str {
opt.as_deref().unwrap_or("<none>")
}
fn format_set(set: &BTreeSet<String>) -> String {
if set.is_empty() {
"<none>".to_string()
} else {
set.iter().cloned().collect::<Vec<_>>().join(", ")
}
}
pub trait Renderer {
fn render<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()>;
}
fn write_text_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
for c in components {
writeln!(writer, "{}", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
}
Ok(())
}
fn write_text_changed<W: Write>(
writer: &mut W,
changes: &[ComponentChange],
) -> std::io::Result<()> {
for c in changes {
writeln!(writer, "{}", c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
write_text_field_changes(writer, &c.changes)?;
}
Ok(())
}
fn write_text_field_changes<W: Write>(
writer: &mut W,
changes: &[FieldChange],
) -> std::io::Result<()> {
for change in changes {
match change {
FieldChange::Version(old, new) => {
writeln!(writer, " Version: {} -> {}", old, new)?;
}
FieldChange::License(old, new) => {
writeln!(
writer,
" License: {} -> {}",
format_set(old),
format_set(new)
)?;
}
FieldChange::Supplier(old, new) => {
writeln!(
writer,
" Supplier: {} -> {}",
format_option(old),
format_option(new)
)?;
}
FieldChange::Purl(old, new) => {
writeln!(
writer,
" Purl: {} -> {}",
format_option(old),
format_option(new)
)?;
}
FieldChange::Description(old, new) => {
writeln!(
writer,
" Description: {} -> {}",
format_option(old),
format_option(new)
)?;
}
FieldChange::Hashes(old, new) => {
writeln!(writer, " Hashes:")?;
for (algo, digest) in old {
if !new.contains_key(algo) {
writeln!(writer, " - {}: {}", algo, digest)?;
} else if new[algo] != *digest {
writeln!(writer, " ~ {}: {} -> {}", algo, digest, new[algo])?;
}
}
for (algo, digest) in new {
if !old.contains_key(algo) {
writeln!(writer, " + {}: {}", algo, digest)?;
}
}
}
}
}
Ok(())
}
pub struct TextRenderer;
impl Renderer for TextRenderer {
fn render<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()> {
if opts.has_warnings() {
writeln!(writer, "[!] Warnings")?;
writeln!(writer, "------------")?;
for w in &opts.old_warnings {
writeln!(writer, "[old] {}", w)?;
}
for w in &opts.new_warnings {
writeln!(writer, "[new] {}", w)?;
}
writeln!(writer)?;
}
writeln!(writer, "Diff Summary")?;
writeln!(writer, "============")?;
writeln!(writer, "Added: {}", diff.added.len())?;
writeln!(writer, "Removed: {}", diff.removed.len())?;
writeln!(writer, "Changed: {}", diff.changed.len())?;
writeln!(writer)?;
if opts.group_by_ecosystem {
let grouped = diff.group_by_ecosystem();
let breakdown = grouped.ecosystem_breakdown();
writeln!(writer, "By Ecosystem")?;
writeln!(writer, "------------")?;
for (eco, counts) in &breakdown {
writeln!(
writer,
"{}: {} added, {} removed, {} changed",
eco, counts.added, counts.removed, counts.changed
)?;
}
writeln!(writer)?;
for (eco, eco_diff) in &grouped.by_ecosystem {
writeln!(writer, "[{}]", eco)?;
writeln!(writer)?;
if !eco_diff.added.is_empty() {
writeln!(writer, "[+] Added")?;
writeln!(writer, "---------")?;
write_text_added(writer, &eco_diff.added)?;
writeln!(writer)?;
}
if !eco_diff.removed.is_empty() {
writeln!(writer, "[-] Removed")?;
writeln!(writer, "-----------")?;
write_text_added(writer, &eco_diff.removed)?;
writeln!(writer)?;
}
if !eco_diff.changed.is_empty() {
writeln!(writer, "[~] Changed")?;
writeln!(writer, "-----------")?;
write_text_changed(writer, &eco_diff.changed)?;
writeln!(writer)?;
}
}
} else {
if !diff.added.is_empty() {
writeln!(writer, "[+] Added")?;
writeln!(writer, "---------")?;
write_text_added(writer, &diff.added)?;
writeln!(writer)?;
}
if !diff.removed.is_empty() {
writeln!(writer, "[-] Removed")?;
writeln!(writer, "-----------")?;
write_text_added(writer, &diff.removed)?;
writeln!(writer)?;
}
if !diff.changed.is_empty() {
writeln!(writer, "[~] Changed")?;
writeln!(writer, "-----------")?;
write_text_changed(writer, &diff.changed)?;
writeln!(writer)?;
}
}
if !diff.edge_diffs.is_empty() {
writeln!(writer, "[~] Edge Changes")?;
writeln!(writer, "----------------")?;
for edge in &diff.edge_diffs {
writeln!(writer, "{}", edge.parent)?;
for removed in &edge.removed {
writeln!(writer, " - {}", removed)?;
}
for added in &edge.added {
writeln!(writer, " + {}", added)?;
}
}
}
Ok(())
}
}
fn write_md_added<W: Write>(writer: &mut W, components: &[Component]) -> std::io::Result<()> {
for c in components {
writeln!(writer, "- `{}`", c.purl.as_deref().unwrap_or(c.id.as_str()))?;
}
Ok(())
}
fn write_md_changed<W: Write>(writer: &mut W, changes: &[ComponentChange]) -> std::io::Result<()> {
for c in changes {
writeln!(
writer,
"#### `{}`",
c.new.purl.as_deref().unwrap_or(c.id.as_str())
)?;
write_md_field_changes(writer, &c.changes)?;
}
Ok(())
}
fn write_md_field_changes<W: Write>(
writer: &mut W,
changes: &[FieldChange],
) -> std::io::Result<()> {
for change in changes {
match change {
FieldChange::Version(old, new) => {
writeln!(writer, "- **Version**: `{}` → `{}`", old, new)?;
}
FieldChange::License(old, new) => {
writeln!(
writer,
"- **License**: `{}` → `{}`",
format_set(old),
format_set(new)
)?;
}
FieldChange::Supplier(old, new) => {
writeln!(
writer,
"- **Supplier**: `{}` → `{}`",
format_option(old),
format_option(new)
)?;
}
FieldChange::Purl(old, new) => {
writeln!(
writer,
"- **Purl**: `{}` → `{}`",
format_option(old),
format_option(new)
)?;
}
FieldChange::Description(old, new) => {
writeln!(
writer,
"- **Description**: `{}` → `{}`",
format_option(old),
format_option(new)
)?;
}
FieldChange::Hashes(old, new) => {
writeln!(writer, "- **Hashes**:")?;
for (algo, digest) in old {
if !new.contains_key(algo) {
writeln!(writer, " - `{}`: removed `{}`", algo, digest)?;
} else if new[algo] != *digest {
writeln!(
writer,
" - `{}`: `{}` → `{}`",
algo, digest, new[algo]
)?;
}
}
for (algo, digest) in new {
if !old.contains_key(algo) {
writeln!(writer, " - `{}`: added `{}`", algo, digest)?;
}
}
}
}
}
Ok(())
}
pub struct MarkdownRenderer;
impl Renderer for MarkdownRenderer {
fn render<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()> {
if opts.has_warnings() {
writeln!(
writer,
"<details><summary><b>Warnings ({})</b></summary>",
opts.warning_count()
)?;
writeln!(writer)?;
for w in &opts.old_warnings {
writeln!(writer, "- **old:** {}", w)?;
}
for w in &opts.new_warnings {
writeln!(writer, "- **new:** {}", w)?;
}
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
writeln!(writer, "### SBOM Diff Summary")?;
writeln!(writer)?;
writeln!(writer, "| Change | Count |")?;
writeln!(writer, "| --- | --- |")?;
writeln!(writer, "| Added | {} |", diff.added.len())?;
writeln!(writer, "| Removed | {} |", diff.removed.len())?;
writeln!(writer, "| Changed | {} |", diff.changed.len())?;
writeln!(writer)?;
if opts.group_by_ecosystem {
let grouped = diff.group_by_ecosystem();
let breakdown = grouped.ecosystem_breakdown();
writeln!(writer, "#### By Ecosystem")?;
writeln!(writer)?;
writeln!(writer, "| Ecosystem | Added | Removed | Changed |")?;
writeln!(writer, "| --- | --- | --- | --- |")?;
for (eco, counts) in &breakdown {
writeln!(
writer,
"| {} | {} | {} | {} |",
eco, counts.added, counts.removed, counts.changed
)?;
}
writeln!(writer)?;
for (eco, eco_diff) in &grouped.by_ecosystem {
writeln!(writer, "#### {}", eco)?;
writeln!(writer)?;
if !eco_diff.added.is_empty() {
writeln!(
writer,
"<details><summary><b>Added ({})</b></summary>",
eco_diff.added.len()
)?;
writeln!(writer)?;
write_md_added(writer, &eco_diff.added)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
if !eco_diff.removed.is_empty() {
writeln!(
writer,
"<details><summary><b>Removed ({})</b></summary>",
eco_diff.removed.len()
)?;
writeln!(writer)?;
write_md_added(writer, &eco_diff.removed)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
if !eco_diff.changed.is_empty() {
writeln!(
writer,
"<details><summary><b>Changed ({})</b></summary>",
eco_diff.changed.len()
)?;
writeln!(writer)?;
write_md_changed(writer, &eco_diff.changed)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
}
} else {
if !diff.added.is_empty() {
writeln!(
writer,
"<details><summary><b>Added ({})</b></summary>",
diff.added.len()
)?;
writeln!(writer)?;
write_md_added(writer, &diff.added)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
if !diff.removed.is_empty() {
writeln!(
writer,
"<details><summary><b>Removed ({})</b></summary>",
diff.removed.len()
)?;
writeln!(writer)?;
write_md_added(writer, &diff.removed)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
if !diff.changed.is_empty() {
writeln!(
writer,
"<details><summary><b>Changed ({})</b></summary>",
diff.changed.len()
)?;
writeln!(writer)?;
write_md_changed(writer, &diff.changed)?;
writeln!(writer, "</details>")?;
writeln!(writer)?;
}
}
if !diff.edge_diffs.is_empty() {
writeln!(
writer,
"<details><summary><b>Edge Changes ({})</b></summary>",
diff.edge_diffs.len()
)?;
writeln!(writer)?;
for edge in &diff.edge_diffs {
writeln!(writer, "#### `{}`", edge.parent)?;
if !edge.removed.is_empty() {
writeln!(writer, "**Removed dependencies:**")?;
for removed in &edge.removed {
writeln!(writer, "- `{}`", removed)?;
}
}
if !edge.added.is_empty() {
writeln!(writer, "**Added dependencies:**")?;
for added in &edge.added {
writeln!(writer, "- `{}`", added)?;
}
}
writeln!(writer)?;
}
writeln!(writer, "</details>")?;
}
Ok(())
}
}
pub struct JsonRenderer;
#[derive(Serialize)]
struct JsonOutput<'a> {
#[serde(flatten)]
diff: &'a Diff,
#[serde(skip_serializing_if = "Option::is_none")]
ecosystem_breakdown: Option<BTreeMap<String, EcosystemCounts>>,
#[serde(skip_serializing_if = "Option::is_none")]
by_ecosystem: Option<&'a GroupedDiff>,
#[serde(skip_serializing_if = "Option::is_none")]
warnings: Option<JsonWarnings<'a>>,
}
#[derive(Serialize)]
struct JsonWarnings<'a> {
old: &'a Vec<String>,
new: &'a Vec<String>,
}
impl Renderer for JsonRenderer {
fn render<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()> {
let warnings = if opts.has_warnings() {
Some(JsonWarnings {
old: &opts.old_warnings,
new: &opts.new_warnings,
})
} else {
None
};
if opts.group_by_ecosystem {
let grouped = diff.group_by_ecosystem();
let output = JsonOutput {
diff,
ecosystem_breakdown: Some(grouped.ecosystem_breakdown()),
by_ecosystem: Some(&grouped),
warnings,
};
serde_json::to_writer_pretty(writer, &output)?;
} else {
let output = JsonOutput {
diff,
ecosystem_breakdown: None,
by_ecosystem: None,
warnings,
};
serde_json::to_writer_pretty(writer, &output)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ComponentChange, Diff, FieldChange};
use sbom_model::Component;
use std::collections::BTreeMap;
fn mock_diff() -> Diff {
let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
let mut c2 = c1.clone();
c2.version = Some("1.1".into());
Diff {
added: vec![Component::new("pkg-b".into(), Some("2.0".into()))],
removed: vec![Component::new("pkg-c".into(), Some("3.0".into()))],
changed: vec![ComponentChange {
id: c2.id.clone(),
old: c1,
new: c2,
changes: vec![FieldChange::Version("1.0".into(), "1.1".into())],
}],
edge_diffs: vec![],
metadata_changed: false,
}
}
fn mock_diff_all_field_changes() -> Diff {
use sbom_model::ComponentId;
use std::collections::BTreeSet;
let c1 = Component::new("pkg-a".into(), Some("1.0".into()));
let mut c2 = c1.clone();
c2.version = Some("1.1".into());
Diff {
added: vec![],
removed: vec![],
changed: vec![ComponentChange {
id: c2.id.clone(),
old: c1,
new: c2,
changes: vec![
FieldChange::Version("1.0".into(), "1.1".into()),
FieldChange::License(
BTreeSet::from(["MIT".into()]),
BTreeSet::from(["Apache-2.0".into()]),
),
FieldChange::Supplier(Some("Old Corp".into()), Some("New Corp".into())),
FieldChange::Purl(
Some("pkg:npm/pkg-a@1.0".into()),
Some("pkg:npm/pkg-a@1.1".into()),
),
FieldChange::Description(
Some("Old description".into()),
Some("New description".into()),
),
FieldChange::Hashes(
BTreeMap::from([("sha256".into(), "aaa".into())]),
BTreeMap::from([("sha256".into(), "bbb".into())]),
),
],
}],
edge_diffs: vec![crate::EdgeDiff {
parent: ComponentId::new(None, &[("name", "parent")]),
added: BTreeSet::from([ComponentId::new(None, &[("name", "child-b")])]),
removed: BTreeSet::from([ComponentId::new(None, &[("name", "child-a")])]),
}],
metadata_changed: false,
}
}
fn mock_diff_empty() -> Diff {
Diff {
added: vec![],
removed: vec![],
changed: vec![],
edge_diffs: vec![],
metadata_changed: false,
}
}
#[test]
fn test_text_renderer() {
let diff = mock_diff();
let mut buf = Vec::new();
TextRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Diff Summary"));
assert!(out.contains("[+] Added"));
assert!(out.contains("[-] Removed"));
assert!(out.contains("[~] Changed"));
}
#[test]
fn test_text_renderer_all_field_changes() {
let diff = mock_diff_all_field_changes();
let mut buf = Vec::new();
TextRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Version: 1.0 -> 1.1"));
assert!(out.contains("License:"));
assert!(out.contains("MIT"));
assert!(out.contains("Apache-2.0"));
assert!(out.contains("Supplier:"));
assert!(out.contains("Old Corp"));
assert!(out.contains("New Corp"));
assert!(out.contains("Purl:"));
assert!(out.contains("Description:"));
assert!(out.contains("Old description"));
assert!(out.contains("New description"));
assert!(out.contains("Hashes:"));
assert!(out.contains("~ sha256: aaa -> bbb"));
assert!(out.contains("[~] Edge Changes"));
}
#[test]
fn test_text_renderer_empty_diff() {
let diff = mock_diff_empty();
let mut buf = Vec::new();
TextRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Added: 0"));
assert!(out.contains("Removed: 0"));
assert!(out.contains("Changed: 0"));
assert!(!out.contains("[+] Added"));
assert!(!out.contains("[-] Removed"));
assert!(!out.contains("[~] Changed"));
}
#[test]
fn test_markdown_renderer() {
let diff = mock_diff();
let mut buf = Vec::new();
MarkdownRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("### SBOM Diff Summary"));
assert!(out.contains("<details>"));
}
#[test]
fn test_markdown_renderer_all_field_changes() {
let diff = mock_diff_all_field_changes();
let mut buf = Vec::new();
MarkdownRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("**Version**"));
assert!(out.contains("**License**"));
assert!(out.contains("**Supplier**"));
assert!(out.contains("**Purl**"));
assert!(out.contains("**Description**"));
assert!(out.contains("**Hashes**:"));
assert!(out.contains("`sha256`: `aaa` → `bbb`"));
assert!(out.contains("Edge Changes"));
assert!(out.contains("**Removed dependencies:**"));
assert!(out.contains("**Added dependencies:**"));
}
#[test]
fn test_markdown_renderer_empty_diff() {
let diff = mock_diff_empty();
let mut buf = Vec::new();
MarkdownRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("| Added | 0 |"));
assert!(!out.contains("<details>"));
}
#[test]
fn test_json_renderer() {
let diff = mock_diff();
let mut buf = Vec::new();
JsonRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let _: serde_json::Value = serde_json::from_slice(&buf).unwrap();
}
#[test]
fn test_json_renderer_all_field_changes() {
let diff = mock_diff_all_field_changes();
let mut buf = Vec::new();
JsonRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(val["changed"].as_array().unwrap().len(), 1);
assert_eq!(val["changed"][0]["changes"].as_array().unwrap().len(), 6);
assert_eq!(val["edge_diffs"].as_array().unwrap().len(), 1);
}
#[test]
fn test_json_renderer_roundtrip() {
let diff = mock_diff_all_field_changes();
let mut buf = Vec::new();
JsonRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let deserialized: Diff = serde_json::from_slice(&buf).unwrap();
assert_eq!(deserialized.changed.len(), diff.changed.len());
assert_eq!(deserialized.edge_diffs.len(), diff.edge_diffs.len());
assert_eq!(deserialized.changed[0].changes, diff.changed[0].changes);
}
fn mock_diff_with_ecosystems() -> Diff {
let mut added_npm = Component::new("express".into(), Some("4.18.0".into()));
added_npm.ecosystem = Some("npm".into());
let mut added_cargo = Component::new("serde".into(), Some("1.0.0".into()));
added_cargo.ecosystem = Some("cargo".into());
let mut removed = Component::new("lodash".into(), Some("4.17.21".into()));
removed.ecosystem = Some("npm".into());
let mut old = Component::new("react".into(), Some("17.0.0".into()));
old.ecosystem = Some("npm".into());
let mut new = old.clone();
new.version = Some("18.0.0".into());
Diff {
added: vec![added_npm, added_cargo],
removed: vec![removed],
changed: vec![ComponentChange {
id: new.id.clone(),
old,
new,
changes: vec![FieldChange::Version("17.0.0".into(), "18.0.0".into())],
}],
edge_diffs: vec![],
metadata_changed: false,
}
}
#[test]
fn test_text_renderer_group_by_ecosystem() {
let diff = mock_diff_with_ecosystems();
let opts = RenderOptions {
group_by_ecosystem: true,
..Default::default()
};
let mut buf = Vec::new();
TextRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("By Ecosystem"));
assert!(out.contains("cargo: 1 added, 0 removed, 0 changed"));
assert!(out.contains("npm: 1 added, 1 removed, 1 changed"));
}
#[test]
fn test_text_renderer_no_ecosystem_by_default() {
let diff = mock_diff_with_ecosystems();
let mut buf = Vec::new();
TextRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("By Ecosystem"));
}
#[test]
fn test_markdown_renderer_group_by_ecosystem() {
let diff = mock_diff_with_ecosystems();
let opts = RenderOptions {
group_by_ecosystem: true,
..Default::default()
};
let mut buf = Vec::new();
MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("#### By Ecosystem"));
assert!(out.contains("| Ecosystem | Added | Removed | Changed |"));
assert!(out.contains("| cargo | 1 | 0 | 0 |"));
assert!(out.contains("| npm | 1 | 1 | 1 |"));
}
#[test]
fn test_json_renderer_group_by_ecosystem() {
let diff = mock_diff_with_ecosystems();
let opts = RenderOptions {
group_by_ecosystem: true,
..Default::default()
};
let mut buf = Vec::new();
JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
let breakdown = &val["ecosystem_breakdown"];
assert!(breakdown.is_object());
assert_eq!(breakdown["npm"]["added"], 1);
assert_eq!(breakdown["npm"]["removed"], 1);
assert_eq!(breakdown["npm"]["changed"], 1);
assert_eq!(breakdown["cargo"]["added"], 1);
assert_eq!(breakdown["cargo"]["removed"], 0);
}
#[test]
fn test_json_renderer_no_ecosystem_by_default() {
let diff = mock_diff_with_ecosystems();
let mut buf = Vec::new();
JsonRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert!(val.get("ecosystem_breakdown").is_none());
}
fn opts_with_warnings() -> RenderOptions {
RenderOptions {
show_warnings: true,
old_warnings: vec!["SPDX: orphaned ref 'SPDXRef-foo'".into()],
new_warnings: vec!["CycloneDX: unknown bom-ref 'bar'".into()],
..Default::default()
}
}
#[test]
fn test_text_renderer_shows_warnings() {
let diff = mock_diff();
let opts = opts_with_warnings();
let mut buf = Vec::new();
TextRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("[!] Warnings"));
assert!(out.contains("[old] SPDX: orphaned ref 'SPDXRef-foo'"));
assert!(out.contains("[new] CycloneDX: unknown bom-ref 'bar'"));
}
#[test]
fn test_text_renderer_hides_warnings_by_default() {
let diff = mock_diff();
let mut buf = Vec::new();
TextRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("[!] Warnings"));
}
#[test]
fn test_markdown_renderer_shows_warnings() {
let diff = mock_diff();
let opts = opts_with_warnings();
let mut buf = Vec::new();
MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("<details><summary><b>Warnings (2)</b></summary>"));
assert!(out.contains("- **old:** SPDX: orphaned ref 'SPDXRef-foo'"));
assert!(out.contains("- **new:** CycloneDX: unknown bom-ref 'bar'"));
}
#[test]
fn test_markdown_renderer_hides_warnings_by_default() {
let diff = mock_diff();
let mut buf = Vec::new();
MarkdownRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("Warnings"));
}
#[test]
fn test_json_renderer_shows_warnings() {
let diff = mock_diff();
let opts = opts_with_warnings();
let mut buf = Vec::new();
JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
let warnings = &val["warnings"];
let old = warnings["old"].as_array().unwrap();
let new = warnings["new"].as_array().unwrap();
assert_eq!(old.len(), 1);
assert_eq!(new.len(), 1);
assert_eq!(old[0], "SPDX: orphaned ref 'SPDXRef-foo'");
assert_eq!(new[0], "CycloneDX: unknown bom-ref 'bar'");
}
#[test]
fn test_json_renderer_hides_warnings_by_default() {
let diff = mock_diff();
let mut buf = Vec::new();
JsonRenderer
.render(&diff, &RenderOptions::default(), &mut buf)
.unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert!(val.get("warnings").is_none());
}
#[test]
fn test_empty_warnings_not_shown() {
let diff = mock_diff();
let opts = RenderOptions {
show_warnings: true,
..Default::default()
};
let mut buf = Vec::new();
TextRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("[!] Warnings"));
let mut buf = Vec::new();
MarkdownRenderer.render(&diff, &opts, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("Warnings"));
let mut buf = Vec::new();
JsonRenderer.render(&diff, &opts, &mut buf).unwrap();
let val: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert!(val.get("warnings").is_none());
}
}