mod csv_format;
mod json;
mod markdown;
mod sarif;
mod text;
pub use csv_format::CsvRenderer;
pub use json::JsonRenderer;
pub use markdown::MarkdownRenderer;
pub use sarif::SarifRenderer;
pub use text::TextRenderer;
use crate::{ComponentChange, Diff, EcosystemCounts, FieldChange};
use sbom_model::DependencyKind;
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()
}
}
pub(super) fn kind_suffix(kind: &DependencyKind) -> &'static str {
match kind {
DependencyKind::Runtime => "",
DependencyKind::Dev => " (dev)",
DependencyKind::Build => " (build)",
DependencyKind::Test => " (test)",
DependencyKind::Optional => " (optional)",
DependencyKind::Provided => " (provided)",
}
}
pub fn format_option(opt: &Option<String>) -> &str {
opt.as_deref().unwrap_or("<none>")
}
pub fn format_set(set: &BTreeSet<String>) -> String {
if set.is_empty() {
"<none>".to_string()
} else {
let mut out = String::new();
for (i, s) in set.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(s);
}
out
}
}
pub trait Renderer {
fn render<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()>;
}
pub trait SummaryRenderer {
fn render_summary<W: Write>(
&self,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> anyhow::Result<()>;
}
pub(super) trait FieldChangeFormatter {
fn field_change<W: Write>(
&self,
w: &mut W,
name: &str,
old: &str,
new: &str,
) -> std::io::Result<()>;
fn hash_header<W: Write>(&self, w: &mut W) -> std::io::Result<()>;
fn hash_removed<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
fn hash_changed<W: Write>(
&self,
w: &mut W,
algo: &str,
old: &str,
new: &str,
) -> std::io::Result<()>;
fn hash_added<W: Write>(&self, w: &mut W, algo: &str, digest: &str) -> std::io::Result<()>;
fn component_header<W: Write>(&self, w: &mut W, id: &str) -> std::io::Result<()>;
}
pub(super) fn write_field_changes<F: FieldChangeFormatter, W: Write>(
fmt: &F,
writer: &mut W,
changes: &[FieldChange],
is_downgrade: bool,
) -> std::io::Result<()> {
for change in changes {
match change {
FieldChange::Version(old, new) => {
let label = if is_downgrade {
"Version (downgrade)"
} else {
"Version"
};
fmt.field_change(writer, label, format_option(old), format_option(new))?;
}
FieldChange::License(old, new) => {
fmt.field_change(writer, "License", &format_set(old), &format_set(new))?;
}
FieldChange::Supplier(old, new) => {
fmt.field_change(writer, "Supplier", format_option(old), format_option(new))?;
}
FieldChange::Purl(old, new) => {
fmt.field_change(writer, "Purl", format_option(old), format_option(new))?;
}
FieldChange::Description(old, new) => {
fmt.field_change(
writer,
"Description",
format_option(old),
format_option(new),
)?;
}
FieldChange::Hashes(old, new) => {
fmt.hash_header(writer)?;
for (algo, digest) in old {
if !new.contains_key(algo) {
fmt.hash_removed(writer, algo, digest)?;
} else if new[algo] != *digest {
fmt.hash_changed(writer, algo, digest, &new[algo])?;
}
}
for (algo, digest) in new {
if !old.contains_key(algo) {
fmt.hash_added(writer, algo, digest)?;
}
}
}
FieldChange::Ecosystem(old, new) => {
fmt.field_change(writer, "Ecosystem", format_option(old), format_option(new))?;
}
}
}
Ok(())
}
pub(super) fn write_changed<F: FieldChangeFormatter, W: Write>(
fmt: &F,
writer: &mut W,
changes: &[ComponentChange],
) -> std::io::Result<()> {
for c in changes {
fmt.component_header(writer, c.new.purl.as_deref().unwrap_or(c.id.as_str()))?;
write_field_changes(fmt, writer, &c.changes, c.is_downgrade)?;
}
Ok(())
}
pub(super) trait SummaryFormatter {
fn write_warnings<W: Write>(&self, w: &mut W, opts: &RenderOptions) -> std::io::Result<()>;
fn write_counts<W: Write>(&self, w: &mut W, diff: &Diff) -> std::io::Result<()>;
fn write_ecosystem_breakdown<W: Write>(
&self,
w: &mut W,
breakdown: &BTreeMap<String, EcosystemCounts>,
) -> std::io::Result<()>;
}
pub(super) fn write_summary<F: SummaryFormatter, W: Write>(
fmt: &F,
diff: &Diff,
opts: &RenderOptions,
writer: &mut W,
) -> std::io::Result<()> {
if opts.has_warnings() {
fmt.write_warnings(writer, opts)?;
}
fmt.write_counts(writer, diff)?;
if opts.group_by_ecosystem {
let breakdown = diff.ecosystem_breakdown();
if !breakdown.is_empty() {
fmt.write_ecosystem_breakdown(writer, &breakdown)?;
}
}
Ok(())
}
pub(super) fn format_vec_or_none(v: &[String]) -> String {
if v.is_empty() {
"<none>".to_string()
} else {
v.join(", ")
}
}
#[cfg(test)]
mod tests;