use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
use crate::diff::{ChangeType, DiffResult};
use crate::model::NormalizedSbom;
use std::fmt::Write;
mod colors {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
pub const LINE_NUM: &str = "\x1b[38;5;242m"; }
#[allow(dead_code)]
pub struct SideBySideReporter {
width: usize,
show_line_numbers: bool,
use_colors: bool,
}
impl SideBySideReporter {
#[must_use]
pub fn new() -> Self {
let width = terminal_width().unwrap_or(120);
Self {
width,
show_line_numbers: true,
use_colors: true,
}
}
#[must_use]
pub const fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
#[must_use]
pub const fn no_colors(mut self) -> Self {
self.use_colors = false;
self
}
const fn col(&self, code: &'static str) -> &'static str {
if self.use_colors { code } else { "" }
}
fn format_header(&self, old_name: &str, new_name: &str) -> String {
let half_width = (self.width - 3) / 2;
format!(
"{}{:<half_width$}{} │ {}{:<half_width$}{}\n",
self.col(colors::BOLD),
truncate(old_name, half_width),
self.col(colors::RESET),
self.col(colors::BOLD),
truncate(new_name, half_width),
self.col(colors::RESET),
)
}
fn format_section_header(&self, title: &str) -> String {
format!(
"\n{}{}═══ {} {}═══{}\n",
self.col(colors::CYAN),
self.col(colors::BOLD),
title,
"═".repeat(self.width.saturating_sub(title.len() + 8)),
self.col(colors::RESET),
)
}
fn format_component_row(
&self,
line_num: usize,
old_text: Option<&str>,
new_text: Option<&str>,
change_type: ChangeType,
) -> String {
let half_width = (self.width - 7) / 2; let num_width = 3;
let (left_num, left_text, right_num, right_text) = match change_type {
ChangeType::Removed => (
format!(
"{}{:>num_width$}{}",
self.col(colors::RED),
line_num,
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::RED),
truncate(old_text.unwrap_or(""), half_width),
self.col(colors::RESET)
),
format!(
"{}{:>num_width$}{}",
self.col(colors::DIM),
".",
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::DIM),
"...",
self.col(colors::RESET)
),
),
ChangeType::Added => (
format!(
"{}{:>num_width$}{}",
self.col(colors::DIM),
".",
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::DIM),
"...",
self.col(colors::RESET)
),
format!(
"{}{:>num_width$}{}",
self.col(colors::GREEN),
line_num,
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::GREEN),
truncate(new_text.unwrap_or(""), half_width),
self.col(colors::RESET)
),
),
ChangeType::Modified => (
format!(
"{}{:>num_width$}{}",
self.col(colors::YELLOW),
line_num,
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::RED),
truncate(old_text.unwrap_or(""), half_width),
self.col(colors::RESET)
),
format!(
"{}{:>num_width$}{}",
self.col(colors::YELLOW),
line_num,
self.col(colors::RESET)
),
format!(
"{}{}{}",
self.col(colors::GREEN),
truncate(new_text.unwrap_or(""), half_width),
self.col(colors::RESET)
),
),
ChangeType::Unchanged => (
format!(
"{}{:>num_width$}{}",
self.col(colors::LINE_NUM),
line_num,
self.col(colors::RESET)
),
truncate(old_text.unwrap_or(""), half_width),
format!(
"{}{:>num_width$}{}",
self.col(colors::LINE_NUM),
line_num,
self.col(colors::RESET)
),
truncate(new_text.unwrap_or(""), half_width),
),
};
let left_visible = strip_ansi(&left_text);
let right_visible = strip_ansi(&right_text);
let left_padding = half_width.saturating_sub(left_visible.len());
let right_padding = half_width.saturating_sub(right_visible.len());
format!(
"{} {}{} │ {} {}{}\n",
left_num,
left_text,
" ".repeat(left_padding),
right_num,
right_text,
" ".repeat(right_padding),
)
}
fn format_vulnerability_row(
&self,
vuln_id: &str,
severity: &str,
component: &str,
is_introduced: bool,
) -> String {
let icon = if is_introduced { "+" } else { "-" };
let color = if is_introduced {
colors::RED
} else {
colors::GREEN
};
let severity_color = match severity.to_lowercase().as_str() {
"critical" => colors::MAGENTA,
"high" => colors::RED,
"medium" => colors::YELLOW,
"low" => colors::CYAN,
_ => colors::WHITE,
};
format!(
" {}{}{} {}{:<16}{} {}{:<10}{} → {}\n",
self.col(color),
icon,
self.col(colors::RESET),
self.col(colors::BOLD),
vuln_id,
self.col(colors::RESET),
self.col(severity_color),
severity,
self.col(colors::RESET),
component,
)
}
}
impl Default for SideBySideReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for SideBySideReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut out = String::new();
let old_name = old_sbom.document.name.as_deref().unwrap_or("Old SBOM");
let new_name = new_sbom.document.name.as_deref().unwrap_or("New SBOM");
writeln!(
out,
"{}sbom-tools{} --- {}",
self.col(colors::CYAN),
self.col(colors::RESET),
old_sbom.document.format
)?;
out.push_str(&self.format_header(old_name, new_name));
let half_width = (self.width - 3) / 2;
writeln!(
out,
"{}{}│{}{}",
self.col(colors::DIM),
"─".repeat(half_width + 4),
"─".repeat(half_width + 4),
self.col(colors::RESET)
)?;
out.push_str(&self.format_section_header("Components"));
let mut line_num = 1;
for comp in &result.components.removed {
let old_text = format!(
"{} {}",
comp.name,
comp.old_version.as_deref().unwrap_or("")
);
out.push_str(&self.format_component_row(
line_num,
Some(&old_text),
None,
ChangeType::Removed,
));
line_num += 1;
}
for comp in &result.components.modified {
let old_text = format!(
"{} {}",
comp.name,
comp.old_version.as_deref().unwrap_or("")
);
let new_text = format!(
"{} {}",
comp.name,
comp.new_version.as_deref().unwrap_or("")
);
out.push_str(&self.format_component_row(
line_num,
Some(&old_text),
Some(&new_text),
ChangeType::Modified,
));
line_num += 1;
}
for comp in &result.components.added {
let new_text = format!(
"{} {}",
comp.name,
comp.new_version.as_deref().unwrap_or("")
);
out.push_str(&self.format_component_row(
line_num,
None,
Some(&new_text),
ChangeType::Added,
));
line_num += 1;
}
if !result.dependencies.added.is_empty() || !result.dependencies.removed.is_empty() {
out.push_str(&self.format_section_header("Dependencies"));
line_num = 1;
for dep in &result.dependencies.removed {
let old_text = format!("{} → {}", short_id(&dep.from), short_id(&dep.to));
out.push_str(&self.format_component_row(
line_num,
Some(&old_text),
None,
ChangeType::Removed,
));
line_num += 1;
}
for dep in &result.dependencies.added {
let new_text = format!("{} → {}", short_id(&dep.from), short_id(&dep.to));
out.push_str(&self.format_component_row(
line_num,
None,
Some(&new_text),
ChangeType::Added,
));
line_num += 1;
}
}
if !result.vulnerabilities.introduced.is_empty()
|| !result.vulnerabilities.resolved.is_empty()
{
out.push_str(&self.format_section_header("Vulnerabilities"));
for vuln in &result.vulnerabilities.resolved {
out.push_str(&self.format_vulnerability_row(
&vuln.id,
&vuln.severity,
&vuln.component_name,
false,
));
}
for vuln in &result.vulnerabilities.introduced {
out.push_str(&self.format_vulnerability_row(
&vuln.id,
&vuln.severity,
&vuln.component_name,
true,
));
}
}
out.push_str(&self.format_section_header("Summary"));
writeln!(
out,
" {}Components:{} {}+{}{} added, {}-{}{} removed, {}~{}{} modified",
self.col(colors::BOLD),
self.col(colors::RESET),
self.col(colors::GREEN),
result.summary.components_added,
self.col(colors::RESET),
self.col(colors::RED),
result.summary.components_removed,
self.col(colors::RESET),
self.col(colors::YELLOW),
result.summary.components_modified,
self.col(colors::RESET),
)?;
if result.summary.vulnerabilities_introduced > 0
|| result.summary.vulnerabilities_resolved > 0
{
writeln!(
out,
" {}Vulnerabilities:{} {}+{}{} introduced, {}-{}{} resolved",
self.col(colors::BOLD),
self.col(colors::RESET),
self.col(colors::RED),
result.summary.vulnerabilities_introduced,
self.col(colors::RESET),
self.col(colors::GREEN),
result.summary.vulnerabilities_resolved,
self.col(colors::RESET),
)?;
}
writeln!(
out,
" {}Semantic Score:{} {}{:.1}{}",
self.col(colors::BOLD),
self.col(colors::RESET),
self.col(colors::CYAN),
result.semantic_score,
self.col(colors::RESET),
)?;
Ok(out)
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut out = String::new();
let name = sbom.document.name.as_deref().unwrap_or("SBOM");
writeln!(
out,
"{}sbom-tools view{} --- {}\n",
self.col(colors::CYAN),
self.col(colors::RESET),
sbom.document.format
)?;
writeln!(
out,
"{}{}{}\n",
self.col(colors::BOLD),
name,
self.col(colors::RESET),
)?;
out.push_str(&self.format_section_header("Components"));
for (i, (_id, comp)) in sbom.components.iter().enumerate() {
let vuln_count = comp.vulnerabilities.len();
let vuln_text = if vuln_count > 0 {
format!(
" {}[{} vulns]{}",
self.col(colors::RED),
vuln_count,
self.col(colors::RESET)
)
} else {
String::new()
};
writeln!(
out,
"{}{:>3}{} {} {}{}{}{}",
self.col(colors::LINE_NUM),
i + 1,
self.col(colors::RESET),
comp.name,
self.col(colors::DIM),
comp.version.as_deref().unwrap_or(""),
self.col(colors::RESET),
vuln_text,
)?;
}
let vulns = sbom.all_vulnerabilities();
if !vulns.is_empty() {
out.push_str(&self.format_section_header("Vulnerabilities"));
for (comp, vuln) in vulns {
let severity = vuln
.severity
.as_ref()
.map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
out.push_str(&self.format_vulnerability_row(&vuln.id, &severity, &comp.name, true));
}
}
Ok(out)
}
fn format(&self) -> ReportFormat {
ReportFormat::SideBySide
}
}
const fn terminal_width() -> Option<usize> {
None
}
fn truncate(s: &str, max_width: usize) -> String {
if s.len() <= max_width {
s.to_string()
} else if max_width > 3 {
format!("{}...", &s[..max_width - 3])
} else {
s[..max_width].to_string()
}
}
fn strip_ansi(s: &str) -> String {
let mut result = String::new();
let mut in_escape = false;
for c in s.chars() {
if c == '\x1b' {
in_escape = true;
} else if in_escape {
if c == 'm' {
in_escape = false;
}
} else {
result.push(c);
}
}
result
}
fn short_id(id: &str) -> String {
if id.starts_with("pkg:") {
if let Some(rest) = id.strip_prefix("pkg:")
&& let Some(slash_pos) = rest.find('/')
{
let name_ver = &rest[slash_pos + 1..];
return name_ver.to_string();
}
}
id.to_string()
}