use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
use crate::quality::{
QualityGrade, QualityReport, QualityScorer, ScoringProfile, ViolationSeverity,
};
use crate::reports::ReportFormat;
use anyhow::{Result, bail};
use serde_json::json;
use std::path::PathBuf;
pub struct QualityConfig {
pub sbom_path: PathBuf,
pub profile: String,
pub output: ReportFormat,
pub output_file: Option<PathBuf>,
pub show_recommendations: bool,
pub show_metrics: bool,
pub min_score: Option<f32>,
pub no_color: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn run_quality(
sbom_path: PathBuf,
profile_name: String,
output: ReportFormat,
output_file: Option<PathBuf>,
show_recommendations: bool,
show_metrics: bool,
min_score: Option<f32>,
no_color: bool,
) -> Result<i32> {
let config = QualityConfig {
sbom_path,
profile: profile_name,
output,
output_file,
show_recommendations,
show_metrics,
min_score,
no_color,
};
run_quality_impl(config)
}
fn run_quality_impl(config: QualityConfig) -> Result<i32> {
let parsed = parse_sbom_with_context(&config.sbom_path, false)?;
let profile = parse_scoring_profile(&config.profile)?;
tracing::info!("Running quality assessment with {:?} profile", profile);
let scorer = QualityScorer::new(profile);
let report = scorer.score(parsed.sbom());
let output_text = match config.output {
ReportFormat::Json => format_quality_json(&report, &config),
ReportFormat::Sarif => format_quality_sarif(&report, &config),
_ => format_quality_report(&report, &config),
};
let output_target = OutputTarget::from_option(config.output_file);
write_output(&output_text, &output_target, false)?;
if let Some(threshold) = config.min_score
&& report.overall_score < threshold
{
tracing::error!(
"Quality score {:.1} is below minimum threshold {:.1}",
report.overall_score,
threshold
);
return Ok(exit_codes::CHANGES_DETECTED);
}
Ok(exit_codes::SUCCESS)
}
fn parse_scoring_profile(profile_name: &str) -> Result<ScoringProfile> {
match profile_name.to_lowercase().as_str() {
"minimal" => Ok(ScoringProfile::Minimal),
"standard" => Ok(ScoringProfile::Standard),
"security" => Ok(ScoringProfile::Security),
"license-compliance" | "license" => Ok(ScoringProfile::LicenseCompliance),
"cra" | "cyber-resilience" => Ok(ScoringProfile::Cra),
"comprehensive" | "full" => Ok(ScoringProfile::Comprehensive),
"cbom" | "cryptographic" => Ok(ScoringProfile::Cbom),
_ => {
bail!(
"Unknown scoring profile: {profile_name}. Valid options: minimal, standard, security, license-compliance, cra, comprehensive, cbom"
);
}
}
}
fn format_quality_json(report: &QualityReport, config: &QualityConfig) -> String {
let output = json!({
"tool": "sbom-tools",
"version": env!("CARGO_PKG_VERSION"),
"sbom": config.sbom_path.file_name().unwrap_or_default().to_string_lossy(),
"profile": config.profile,
"report": report,
});
serde_json::to_string_pretty(&output).unwrap_or_default()
}
fn format_quality_sarif(report: &QualityReport, config: &QualityConfig) -> String {
let mut results = Vec::new();
for violation in &report.compliance.violations {
let level = match violation.severity {
ViolationSeverity::Error => "error",
ViolationSeverity::Warning => "warning",
ViolationSeverity::Info => "note",
};
results.push(json!({
"ruleId": format!("QUALITY-{}", violation.category.name().to_uppercase().replace(' ', "-")),
"level": level,
"message": { "text": violation.message },
"properties": {
"requirement": violation.requirement,
"category": violation.category.name(),
"remediation": violation.remediation_guidance(),
"element": violation.element,
}
}));
}
for rec in &report.recommendations {
let level = match rec.priority {
1 => "error",
2 => "warning",
_ => "note",
};
results.push(json!({
"ruleId": format!("QUALITY-REC-{}", rec.category.name().to_uppercase().replace(' ', "-")),
"level": level,
"message": {
"text": format!("{} ({} affected, +{:.1} impact)", rec.message, rec.affected_count, rec.impact)
},
"properties": {
"priority": rec.priority,
"category": rec.category.name(),
"affected_count": rec.affected_count,
"impact": rec.impact,
}
}));
}
let sarif = json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "sbom-tools",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/anthropics/sbom-tools",
}
},
"results": results,
"properties": {
"sbom": config.sbom_path.file_name().unwrap_or_default().to_string_lossy(),
"profile": config.profile,
"overall_score": report.overall_score,
"grade": report.grade.letter(),
"compliant": report.compliance.is_compliant,
}
}]
});
serde_json::to_string_pretty(&sarif).unwrap_or_default()
}
fn format_quality_report(report: &QualityReport, config: &QualityConfig) -> String {
let mut lines = Vec::new();
let use_color = !config.no_color && std::env::var("NO_COLOR").is_err();
let (grade_color, reset) = if use_color {
let color = match report.grade {
QualityGrade::A | QualityGrade::B => "\x1b[32m", QualityGrade::C | QualityGrade::D => "\x1b[33m", QualityGrade::F => "\x1b[31m", };
(color, "\x1b[0m")
} else {
("", "")
};
lines.push(format!(
"SBOM Quality Report: {}",
config
.sbom_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
));
lines.push(format!("Profile: {}", config.profile));
lines.push(String::new());
lines.push(format!(
"Overall Score: {}{:.1}/100 (Grade: {}){}",
grade_color,
report.overall_score,
report.grade.letter(),
reset
));
lines.push(String::new());
lines.push("Category Scores:".to_string());
lines.push(format!(
" Completeness: {:.1}/100",
report.completeness_score
));
lines.push(format!(
" Identifiers: {:.1}/100",
report.identifier_score
));
lines.push(format!(
" Licenses: {:.1}/100",
report.license_score
));
lines.push(match report.vulnerability_score {
Some(score) => format!(" Vulnerabilities: {score:.1}/100"),
None => " Vulnerabilities: N/A".to_string(),
});
lines.push(format!(
" Dependencies: {:.1}/100",
report.dependency_score
));
lines.push(String::new());
let compliance_status = if report.compliance.is_compliant {
format!(
"{}COMPLIANT{}",
if use_color { "\x1b[32m" } else { "" },
reset
)
} else {
format!(
"{}NON-COMPLIANT{}",
if use_color { "\x1b[31m" } else { "" },
reset
)
};
lines.push(format!(
"Compliance ({}): {} ({} errors, {} warnings)",
report.compliance.level.name(),
compliance_status,
report.compliance.error_count,
report.compliance.warning_count
));
lines.push(String::new());
if config.show_metrics {
lines.push("Detailed Metrics:".to_string());
lines.push(format!(
" Total Components: {}",
report.completeness_metrics.total_components
));
lines.push(format!(
" With Version: {:.1}%",
report.completeness_metrics.components_with_version
));
lines.push(format!(
" With PURL: {:.1}%",
report.completeness_metrics.components_with_purl
));
lines.push(format!(
" With License: {:.1}%",
report.completeness_metrics.components_with_licenses
));
lines.push(format!(
" With Supplier: {:.1}%",
report.completeness_metrics.components_with_supplier
));
lines.push(format!(
" With Hashes: {:.1}%",
report.completeness_metrics.components_with_hashes
));
lines.push(String::new());
lines.push(" Identifier Quality:".to_string());
lines.push(format!(
" Valid PURLs: {}",
report.identifier_metrics.valid_purls
));
lines.push(format!(
" Valid CPEs: {}",
report.identifier_metrics.valid_cpes
));
lines.push(format!(
" Missing IDs: {}",
report.identifier_metrics.missing_all_identifiers
));
lines.push(format!(
" Ecosystems: {}",
report.identifier_metrics.ecosystems.join(", ")
));
lines.push(String::new());
lines.push(" Dependency Graph:".to_string());
lines.push(format!(
" Total Edges: {}",
report.dependency_metrics.total_dependencies
));
lines.push(format!(
" Orphan Nodes: {}",
report.dependency_metrics.orphan_components
));
if let Some(simplicity) = report.dependency_metrics.software_complexity_index {
let level = report
.dependency_metrics
.complexity_level
.as_ref()
.map_or("N/A", |l| l.label());
lines.push(format!(" Complexity: {simplicity:.0}/100 ({level})"));
if let Some(ref f) = report.dependency_metrics.complexity_factors {
lines.push(format!(
" Volume: {:.2} Depth: {:.2} Fanout: {:.2} Cycles: {:.2} Fragmentation: {:.2}",
f.dependency_volume, f.normalized_depth, f.fanout_concentration, f.cycle_ratio, f.fragmentation
));
}
} else {
lines.push(" Complexity: N/A (graph analysis skipped)".to_string());
}
lines.push(String::new());
}
if config.show_recommendations && !report.recommendations.is_empty() {
lines.push("Recommendations:".to_string());
for rec in report.recommendations.iter().take(10) {
let priority_indicator = if use_color {
match rec.priority {
1 => "\x1b[31m[P1]\x1b[0m",
2 => "\x1b[33m[P2]\x1b[0m",
3 => "\x1b[34m[P3]\x1b[0m",
_ => "[P4+]",
}
} else {
match rec.priority {
1 => "[P1]",
2 => "[P2]",
3 => "[P3]",
_ => "[P4+]",
}
};
lines.push(format!(
" {} {} ({} affected, +{:.1} impact)",
priority_indicator, rec.message, rec.affected_count, rec.impact
));
}
lines.push(String::new());
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_scoring_profile() {
assert!(matches!(
parse_scoring_profile("minimal").unwrap(),
ScoringProfile::Minimal
));
assert!(matches!(
parse_scoring_profile("standard").unwrap(),
ScoringProfile::Standard
));
assert!(matches!(
parse_scoring_profile("security").unwrap(),
ScoringProfile::Security
));
assert!(matches!(
parse_scoring_profile("license-compliance").unwrap(),
ScoringProfile::LicenseCompliance
));
assert!(matches!(
parse_scoring_profile("cra").unwrap(),
ScoringProfile::Cra
));
assert!(matches!(
parse_scoring_profile("comprehensive").unwrap(),
ScoringProfile::Comprehensive
));
}
#[test]
fn test_parse_scoring_profile_case_insensitive() {
assert!(matches!(
parse_scoring_profile("MINIMAL").unwrap(),
ScoringProfile::Minimal
));
assert!(matches!(
parse_scoring_profile("Standard").unwrap(),
ScoringProfile::Standard
));
}
#[test]
fn test_parse_scoring_profile_invalid() {
assert!(parse_scoring_profile("invalid").is_err());
}
#[test]
fn test_parse_scoring_profile_aliases() {
assert!(matches!(
parse_scoring_profile("license").unwrap(),
ScoringProfile::LicenseCompliance
));
assert!(matches!(
parse_scoring_profile("full").unwrap(),
ScoringProfile::Comprehensive
));
assert!(matches!(
parse_scoring_profile("cyber-resilience").unwrap(),
ScoringProfile::Cra
));
}
}