use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
use crate::diff::DiffResult;
use crate::model::NormalizedSbom;
fn ansi_color(text: &str, color: &str, colored: bool) -> String {
if colored {
match color {
"red" => format!("\x1b[31m{text}\x1b[0m"),
"green" => format!("\x1b[32m{text}\x1b[0m"),
"yellow" => format!("\x1b[33m{text}\x1b[0m"),
"cyan" => format!("\x1b[36m{text}\x1b[0m"),
"bold" => format!("\x1b[1m{text}\x1b[0m"),
"dim" => format!("\x1b[2m{text}\x1b[0m"),
_ => text.to_string(),
}
} else {
text.to_string()
}
}
pub struct SummaryReporter {
colored: bool,
}
impl SummaryReporter {
#[must_use]
pub const fn new() -> Self {
Self { colored: true }
}
#[must_use]
pub const fn no_color(mut self) -> Self {
self.colored = false;
self
}
fn color(&self, text: &str, color: &str) -> String {
ansi_color(text, color, self.colored)
}
}
impl Default for SummaryReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for SummaryReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut lines = Vec::new();
lines.push(self.color("SBOM Diff Summary", "bold"));
lines.push(self.color("─".repeat(40).as_str(), "dim"));
let old_name = old_sbom.document.name.as_deref().unwrap_or("old");
let new_name = new_sbom.document.name.as_deref().unwrap_or("new");
lines.push(format!(
"{} {} → {}",
self.color("Files:", "cyan"),
old_name,
new_name
));
lines.push(format!(
"{} {} → {} components",
self.color("Size:", "cyan"),
old_sbom.component_count(),
new_sbom.component_count()
));
lines.push(String::new());
lines.push(self.color("Changes:", "bold"));
let added = result.summary.components_added;
let removed = result.summary.components_removed;
let modified = result.summary.components_modified;
if added > 0 {
lines.push(format!(
" {} {} added",
self.color(&format!("+{added}"), "green"),
if added == 1 {
"component"
} else {
"components"
}
));
}
if removed > 0 {
lines.push(format!(
" {} {} removed",
self.color(&format!("-{removed}"), "red"),
if removed == 1 {
"component"
} else {
"components"
}
));
}
if modified > 0 {
lines.push(format!(
" {} {} modified",
self.color(&format!("~{modified}"), "yellow"),
if modified == 1 {
"component"
} else {
"components"
}
));
}
if added == 0 && removed == 0 && modified == 0 {
lines.push(format!(" {}", self.color("No changes", "dim")));
}
let vulns_intro = result.summary.vulnerabilities_introduced;
let vulns_resolved = result.summary.vulnerabilities_resolved;
if vulns_intro > 0 || vulns_resolved > 0 {
lines.push(String::new());
lines.push(self.color("Vulnerabilities:", "bold"));
if vulns_intro > 0 {
lines.push(format!(
" {} {} introduced",
self.color(&format!("!{vulns_intro}"), "red"),
if vulns_intro == 1 {
"vulnerability"
} else {
"vulnerabilities"
}
));
}
if vulns_resolved > 0 {
lines.push(format!(
" {} {} resolved",
self.color(&format!("✓{vulns_resolved}"), "green"),
if vulns_resolved == 1 {
"vulnerability"
} else {
"vulnerabilities"
}
));
}
}
{
let eol_counts = count_eol_statuses(new_sbom);
if eol_counts.total > 0 {
lines.push(String::new());
lines.push(self.color("End-of-Life:", "bold"));
let mut parts = Vec::new();
if eol_counts.eol > 0 {
parts.push(self.color(&format!("{} EOL", eol_counts.eol), "red"));
}
if eol_counts.approaching > 0 {
parts.push(
self.color(&format!("{} approaching", eol_counts.approaching), "yellow"),
);
}
if eol_counts.supported > 0 {
parts.push(self.color(&format!("{} supported", eol_counts.supported), "green"));
}
if eol_counts.security_only > 0 {
parts.push(format!("{} security-only", eol_counts.security_only));
}
if eol_counts.unknown > 0 {
parts.push(format!("{} unknown", eol_counts.unknown));
}
lines.push(format!(" {}", parts.join(", ")));
}
}
if let Some(ref summary) = result.graph_summary
&& summary.total_changes > 0
{
lines.push(String::new());
lines.push(self.color("Graph Changes:", "bold"));
lines.push(format!(
" {} added, {} removed, {} rel changed, {} reparented, {} depth changes",
summary.dependencies_added,
summary.dependencies_removed,
summary.relationship_changed,
summary.reparented,
summary.depth_changed,
));
let mut impact_parts = Vec::new();
if summary.by_impact.critical > 0 {
impact_parts
.push(self.color(&format!("{} critical", summary.by_impact.critical), "red"));
}
if summary.by_impact.high > 0 {
impact_parts
.push(self.color(&format!("{} high", summary.by_impact.high), "yellow"));
}
if summary.by_impact.medium > 0 {
impact_parts.push(format!("{} medium", summary.by_impact.medium));
}
if summary.by_impact.low > 0 {
impact_parts.push(format!("{} low", summary.by_impact.low));
}
if !impact_parts.is_empty() {
lines.push(format!(" By impact: {}", impact_parts.join(", ")));
}
}
lines.push(String::new());
let score = result.semantic_score;
let score_color = if score > 90.0 {
"green"
} else if score > 70.0 {
"yellow"
} else {
"red"
};
lines.push(format!(
"{} {}",
self.color("Similarity:", "cyan"),
self.color(&format!("{score:.1}%"), score_color)
));
Ok(lines.join("\n"))
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut lines = Vec::new();
lines.push(self.color("SBOM Summary", "bold"));
lines.push(self.color("─".repeat(40).as_str(), "dim"));
if let Some(name) = &sbom.document.name {
lines.push(format!("{} {}", self.color("Name:", "cyan"), name));
}
lines.push(format!(
"{} {}",
self.color("Format:", "cyan"),
sbom.document.format
));
lines.push(format!(
"{} {}",
self.color("Components:", "cyan"),
sbom.component_count()
));
lines.push(format!(
"{} {}",
self.color("Dependencies:", "cyan"),
sbom.edges.len()
));
let ecosystems: Vec<_> = sbom
.ecosystems()
.iter()
.map(std::string::ToString::to_string)
.collect();
if !ecosystems.is_empty() {
lines.push(format!(
"{} {}",
self.color("Ecosystems:", "cyan"),
ecosystems.join(", ")
));
}
let counts = sbom.vulnerability_counts();
let total_vulns = counts.critical + counts.high + counts.medium + counts.low;
if total_vulns > 0 {
lines.push(String::new());
lines.push(self.color("Vulnerabilities:", "bold"));
if counts.critical > 0 {
lines.push(format!(
" {}",
self.color(&format!("Critical: {}", counts.critical), "red")
));
}
if counts.high > 0 {
lines.push(format!(
" {}",
self.color(&format!("High: {}", counts.high), "red")
));
}
if counts.medium > 0 {
lines.push(format!(
" {}",
self.color(&format!("Medium: {}", counts.medium), "yellow")
));
}
if counts.low > 0 {
lines.push(format!(
" {}",
self.color(&format!("Low: {}", counts.low), "dim")
));
}
}
let crypto_metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
if crypto_metrics.has_data() {
lines.push(String::new());
lines.push(self.color(
&format!("Crypto: {} assets", crypto_metrics.total_crypto_components),
"bold",
));
lines.push(format!(
" Algorithms: {} | Certificates: {} | Keys: {} | Protocols: {}",
crypto_metrics.algorithms_count,
crypto_metrics.certificates_count,
crypto_metrics.keys_count,
crypto_metrics.protocols_count,
));
if crypto_metrics.algorithms_count > 0 {
let readiness = crypto_metrics.quantum_readiness_score();
let color = if readiness >= 80.0 {
"green"
} else if readiness >= 40.0 {
"yellow"
} else {
"red"
};
lines.push(format!(
" {}",
self.color(&format!("Quantum readiness: {readiness:.0}%"), color)
));
}
if crypto_metrics.weak_algorithm_count > 0 {
lines.push(format!(
" {}",
self.color(
&format!("Weak algorithms: {}", crypto_metrics.weak_algorithm_count),
"red"
)
));
}
if crypto_metrics.expired_certificates > 0 {
lines.push(format!(
" {}",
self.color(
&format!(
"Expired certificates: {}",
crypto_metrics.expired_certificates
),
"red"
)
));
}
if crypto_metrics.compromised_keys > 0 {
lines.push(format!(
" {}",
self.color(
&format!("Compromised keys: {}", crypto_metrics.compromised_keys),
"red"
)
));
}
}
Ok(lines.join("\n"))
}
fn format(&self) -> ReportFormat {
ReportFormat::Summary
}
}
pub struct TableReporter {
colored: bool,
}
impl TableReporter {
#[must_use]
pub const fn new() -> Self {
Self { colored: true }
}
#[must_use]
pub const fn no_color(mut self) -> Self {
self.colored = false;
self
}
fn color(&self, text: &str, color: &str) -> String {
ansi_color(text, color, self.colored)
}
}
impl Default for TableReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for TableReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
_old_sbom: &NormalizedSbom,
_new_sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut lines = Vec::new();
lines.push(format!(
"{:<12} {:<40} {:<15} {:<15}",
self.color("STATUS", "bold"),
self.color("COMPONENT", "bold"),
self.color("OLD VERSION", "bold"),
self.color("NEW VERSION", "bold")
));
lines.push("─".repeat(85));
for comp in &result.components.added {
let version = comp.new_version.as_deref().unwrap_or("-");
lines.push(format!(
"{:<12} {:<40} {:<15} {:<15}",
self.color("+ Added", "green"),
truncate(&comp.name, 40),
"-",
version
));
}
for comp in &result.components.removed {
let version = comp.old_version.as_deref().unwrap_or("-");
lines.push(format!(
"{:<12} {:<40} {:<15} {:<15}",
self.color("- Removed", "red"),
truncate(&comp.name, 40),
version,
"-"
));
}
for comp in &result.components.modified {
let old_ver = comp.old_version.as_deref().unwrap_or("-");
let new_ver = comp.new_version.as_deref().unwrap_or("-");
lines.push(format!(
"{:<12} {:<40} {:<15} {:<15}",
self.color("~ Modified", "yellow"),
truncate(&comp.name, 40),
old_ver,
new_ver
));
}
if !result.vulnerabilities.introduced.is_empty() {
lines.push(String::new());
lines.push(format!(
"{:<12} {:<20} {:<10} {:<40}",
self.color("VULNS", "bold"),
self.color("ID", "bold"),
self.color("SEVERITY", "bold"),
self.color("COMPONENT", "bold")
));
lines.push("─".repeat(85));
for vuln in &result.vulnerabilities.introduced {
let severity_colored = match vuln.severity.to_lowercase().as_str() {
"critical" | "high" => self.color(&vuln.severity, "red"),
"medium" => self.color(&vuln.severity, "yellow"),
_ => vuln.severity.clone(),
};
lines.push(format!(
"{:<12} {:<20} {:<10} {:<40}",
self.color("! NEW", "red"),
truncate(&vuln.id, 20),
severity_colored,
truncate(&vuln.component_name, 40)
));
}
}
lines.push(String::new());
lines.push(format!(
"Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
result.summary.components_added,
result.summary.components_removed,
result.summary.components_modified,
result.summary.vulnerabilities_introduced,
result.summary.vulnerabilities_resolved,
result.semantic_score
));
Ok(lines.join("\n"))
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
_config: &ReportConfig,
) -> Result<String, ReportError> {
let mut lines = Vec::new();
lines.push(format!(
"{:<40} {:<15} {:<20} {:<10}",
self.color("COMPONENT", "bold"),
self.color("VERSION", "bold"),
self.color("LICENSE", "bold"),
self.color("VULNS", "bold")
));
lines.push("─".repeat(90));
let mut components: Vec<_> = sbom.components.values().collect();
components.sort_by(|a, b| a.name.cmp(&b.name));
for comp in components.iter().take(50) {
let version = comp.version.as_deref().unwrap_or("-");
let license = comp
.licenses
.declared
.first()
.map_or("-", |l| l.expression.as_str());
let vulns = comp.vulnerabilities.len();
let vuln_display = if vulns > 0 {
self.color(&vulns.to_string(), "red")
} else {
"0".to_string()
};
lines.push(format!(
"{:<40} {:<15} {:<20} {:<10}",
truncate(&comp.name, 40),
truncate(version, 15),
truncate(license, 20),
vuln_display
));
}
if components.len() > 50 {
lines.push(self.color(
&format!("... and {} more components", components.len() - 50),
"dim",
));
}
lines.push(String::new());
let counts = sbom.vulnerability_counts();
let unknown_str = if counts.unknown > 0 {
format!(", {} unknown", counts.unknown)
} else {
String::new()
};
lines.push(format!(
"Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
sbom.component_count(),
sbom.edges.len(),
counts.critical,
counts.high,
counts.medium,
counts.low,
unknown_str
));
Ok(lines.join("\n"))
}
fn format(&self) -> ReportFormat {
ReportFormat::Table
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else if max_len > 3 {
let end = floor_char_boundary(s, max_len - 3);
format!("{}...", &s[..end])
} else {
let end = floor_char_boundary(s, max_len);
s[..end].to_string()
}
}
struct EolCounts {
total: usize,
eol: usize,
approaching: usize,
supported: usize,
security_only: usize,
unknown: usize,
}
fn count_eol_statuses(sbom: &NormalizedSbom) -> EolCounts {
use crate::model::EolStatus;
let mut counts = EolCounts {
total: 0,
eol: 0,
approaching: 0,
supported: 0,
security_only: 0,
unknown: 0,
};
for comp in sbom.components.values() {
if let Some(eol) = &comp.eol {
counts.total += 1;
match eol.status {
EolStatus::EndOfLife => counts.eol += 1,
EolStatus::ApproachingEol => counts.approaching += 1,
EolStatus::Supported => counts.supported += 1,
EolStatus::SecurityOnly => counts.security_only += 1,
EolStatus::Unknown => counts.unknown += 1,
}
}
}
counts
}
const fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
s.len()
} else {
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
}