1use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
6use crate::quality::{
7 QualityGrade, QualityReport, QualityScorer, ScoringProfile, ViolationSeverity,
8};
9use crate::reports::ReportFormat;
10use anyhow::{Result, bail};
11use serde_json::json;
12use std::path::PathBuf;
13
14pub struct QualityConfig {
16 pub sbom_path: PathBuf,
17 pub profile: String,
18 pub output: ReportFormat,
19 pub output_file: Option<PathBuf>,
20 pub show_recommendations: bool,
21 pub show_metrics: bool,
22 pub min_score: Option<f32>,
23 pub no_color: bool,
24}
25
26#[allow(clippy::too_many_arguments)]
31pub fn run_quality(
32 sbom_path: PathBuf,
33 profile_name: String,
34 output: ReportFormat,
35 output_file: Option<PathBuf>,
36 show_recommendations: bool,
37 show_metrics: bool,
38 min_score: Option<f32>,
39 no_color: bool,
40) -> Result<i32> {
41 let config = QualityConfig {
42 sbom_path,
43 profile: profile_name,
44 output,
45 output_file,
46 show_recommendations,
47 show_metrics,
48 min_score,
49 no_color,
50 };
51
52 run_quality_impl(config)
53}
54
55fn run_quality_impl(config: QualityConfig) -> Result<i32> {
56 let parsed = parse_sbom_with_context(&config.sbom_path, false)?;
57
58 let profile = parse_scoring_profile(&config.profile)?;
60
61 tracing::info!("Running quality assessment with {:?} profile", profile);
62
63 let scorer = QualityScorer::new(profile);
64 let report = scorer.score(parsed.sbom());
65
66 let output_text = match config.output {
68 ReportFormat::Json => format_quality_json(&report, &config),
69 ReportFormat::Sarif => format_quality_sarif(&report, &config),
70 _ => format_quality_report(&report, &config),
71 };
72
73 let output_target = OutputTarget::from_option(config.output_file);
75 write_output(&output_text, &output_target, false)?;
76
77 if let Some(threshold) = config.min_score
79 && report.overall_score < threshold
80 {
81 tracing::error!(
82 "Quality score {:.1} is below minimum threshold {:.1}",
83 report.overall_score,
84 threshold
85 );
86 return Ok(exit_codes::CHANGES_DETECTED);
87 }
88
89 Ok(exit_codes::SUCCESS)
90}
91
92fn parse_scoring_profile(profile_name: &str) -> Result<ScoringProfile> {
94 match profile_name.to_lowercase().as_str() {
95 "minimal" => Ok(ScoringProfile::Minimal),
96 "standard" => Ok(ScoringProfile::Standard),
97 "security" => Ok(ScoringProfile::Security),
98 "license-compliance" | "license" => Ok(ScoringProfile::LicenseCompliance),
99 "cra" | "cyber-resilience" => Ok(ScoringProfile::Cra),
100 "comprehensive" | "full" => Ok(ScoringProfile::Comprehensive),
101 _ => {
102 bail!(
103 "Unknown scoring profile: {profile_name}. Valid options: minimal, standard, security, license-compliance, cra, comprehensive"
104 );
105 }
106 }
107}
108
109fn format_quality_json(report: &QualityReport, config: &QualityConfig) -> String {
111 let output = json!({
112 "tool": "sbom-tools",
113 "version": env!("CARGO_PKG_VERSION"),
114 "sbom": config.sbom_path.file_name().unwrap_or_default().to_string_lossy(),
115 "profile": config.profile,
116 "report": report,
117 });
118 serde_json::to_string_pretty(&output).unwrap_or_default()
119}
120
121fn format_quality_sarif(report: &QualityReport, config: &QualityConfig) -> String {
123 let mut results = Vec::new();
124
125 for violation in &report.compliance.violations {
127 let level = match violation.severity {
128 ViolationSeverity::Error => "error",
129 ViolationSeverity::Warning => "warning",
130 ViolationSeverity::Info => "note",
131 };
132 results.push(json!({
133 "ruleId": format!("QUALITY-{}", violation.category.name().to_uppercase().replace(' ', "-")),
134 "level": level,
135 "message": { "text": violation.message },
136 "properties": {
137 "requirement": violation.requirement,
138 "category": violation.category.name(),
139 "remediation": violation.remediation_guidance(),
140 "element": violation.element,
141 }
142 }));
143 }
144
145 for rec in &report.recommendations {
147 let level = match rec.priority {
148 1 => "error",
149 2 => "warning",
150 _ => "note",
151 };
152 results.push(json!({
153 "ruleId": format!("QUALITY-REC-{}", rec.category.name().to_uppercase().replace(' ', "-")),
154 "level": level,
155 "message": {
156 "text": format!("{} ({} affected, +{:.1} impact)", rec.message, rec.affected_count, rec.impact)
157 },
158 "properties": {
159 "priority": rec.priority,
160 "category": rec.category.name(),
161 "affected_count": rec.affected_count,
162 "impact": rec.impact,
163 }
164 }));
165 }
166
167 let sarif = json!({
168 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
169 "version": "2.1.0",
170 "runs": [{
171 "tool": {
172 "driver": {
173 "name": "sbom-tools",
174 "version": env!("CARGO_PKG_VERSION"),
175 "informationUri": "https://github.com/anthropics/sbom-tools",
176 }
177 },
178 "results": results,
179 "properties": {
180 "sbom": config.sbom_path.file_name().unwrap_or_default().to_string_lossy(),
181 "profile": config.profile,
182 "overall_score": report.overall_score,
183 "grade": report.grade.letter(),
184 "compliant": report.compliance.is_compliant,
185 }
186 }]
187 });
188
189 serde_json::to_string_pretty(&sarif).unwrap_or_default()
190}
191
192fn format_quality_report(report: &QualityReport, config: &QualityConfig) -> String {
194 let mut lines = Vec::new();
195 let use_color = !config.no_color && std::env::var("NO_COLOR").is_err();
196
197 let (grade_color, reset) = if use_color {
199 let color = match report.grade {
200 QualityGrade::A | QualityGrade::B => "\x1b[32m", QualityGrade::C | QualityGrade::D => "\x1b[33m", QualityGrade::F => "\x1b[31m", };
204 (color, "\x1b[0m")
205 } else {
206 ("", "")
207 };
208
209 lines.push(format!(
211 "SBOM Quality Report: {}",
212 config
213 .sbom_path
214 .file_name()
215 .unwrap_or_default()
216 .to_string_lossy()
217 ));
218 lines.push(format!("Profile: {}", config.profile));
219 lines.push(String::new());
220
221 lines.push(format!(
223 "Overall Score: {}{:.1}/100 (Grade: {}){}",
224 grade_color,
225 report.overall_score,
226 report.grade.letter(),
227 reset
228 ));
229 lines.push(String::new());
230
231 lines.push("Category Scores:".to_string());
233 lines.push(format!(
234 " Completeness: {:.1}/100",
235 report.completeness_score
236 ));
237 lines.push(format!(
238 " Identifiers: {:.1}/100",
239 report.identifier_score
240 ));
241 lines.push(format!(
242 " Licenses: {:.1}/100",
243 report.license_score
244 ));
245 lines.push(match report.vulnerability_score {
246 Some(score) => format!(" Vulnerabilities: {score:.1}/100"),
247 None => " Vulnerabilities: N/A".to_string(),
248 });
249 lines.push(format!(
250 " Dependencies: {:.1}/100",
251 report.dependency_score
252 ));
253 lines.push(String::new());
254
255 let compliance_status = if report.compliance.is_compliant {
257 format!(
258 "{}COMPLIANT{}",
259 if use_color { "\x1b[32m" } else { "" },
260 reset
261 )
262 } else {
263 format!(
264 "{}NON-COMPLIANT{}",
265 if use_color { "\x1b[31m" } else { "" },
266 reset
267 )
268 };
269 lines.push(format!(
270 "Compliance ({}): {} ({} errors, {} warnings)",
271 report.compliance.level.name(),
272 compliance_status,
273 report.compliance.error_count,
274 report.compliance.warning_count
275 ));
276 lines.push(String::new());
277
278 if config.show_metrics {
280 lines.push("Detailed Metrics:".to_string());
281 lines.push(format!(
282 " Total Components: {}",
283 report.completeness_metrics.total_components
284 ));
285 lines.push(format!(
286 " With Version: {:.1}%",
287 report.completeness_metrics.components_with_version
288 ));
289 lines.push(format!(
290 " With PURL: {:.1}%",
291 report.completeness_metrics.components_with_purl
292 ));
293 lines.push(format!(
294 " With License: {:.1}%",
295 report.completeness_metrics.components_with_licenses
296 ));
297 lines.push(format!(
298 " With Supplier: {:.1}%",
299 report.completeness_metrics.components_with_supplier
300 ));
301 lines.push(format!(
302 " With Hashes: {:.1}%",
303 report.completeness_metrics.components_with_hashes
304 ));
305 lines.push(String::new());
306
307 lines.push(" Identifier Quality:".to_string());
308 lines.push(format!(
309 " Valid PURLs: {}",
310 report.identifier_metrics.valid_purls
311 ));
312 lines.push(format!(
313 " Valid CPEs: {}",
314 report.identifier_metrics.valid_cpes
315 ));
316 lines.push(format!(
317 " Missing IDs: {}",
318 report.identifier_metrics.missing_all_identifiers
319 ));
320 lines.push(format!(
321 " Ecosystems: {}",
322 report.identifier_metrics.ecosystems.join(", ")
323 ));
324 lines.push(String::new());
325
326 lines.push(" Dependency Graph:".to_string());
327 lines.push(format!(
328 " Total Edges: {}",
329 report.dependency_metrics.total_dependencies
330 ));
331 lines.push(format!(
332 " Orphan Nodes: {}",
333 report.dependency_metrics.orphan_components
334 ));
335 if let Some(simplicity) = report.dependency_metrics.software_complexity_index {
337 let level = report
338 .dependency_metrics
339 .complexity_level
340 .as_ref()
341 .map_or("N/A", |l| l.label());
342 lines.push(format!(" Complexity: {simplicity:.0}/100 ({level})"));
343 if let Some(ref f) = report.dependency_metrics.complexity_factors {
344 lines.push(format!(
345 " Volume: {:.2} Depth: {:.2} Fanout: {:.2} Cycles: {:.2} Fragmentation: {:.2}",
346 f.dependency_volume, f.normalized_depth, f.fanout_concentration, f.cycle_ratio, f.fragmentation
347 ));
348 }
349 } else {
350 lines.push(" Complexity: N/A (graph analysis skipped)".to_string());
351 }
352 lines.push(String::new());
353 }
354
355 if config.show_recommendations && !report.recommendations.is_empty() {
357 lines.push("Recommendations:".to_string());
358 for rec in report.recommendations.iter().take(10) {
359 let priority_indicator = if use_color {
360 match rec.priority {
361 1 => "\x1b[31m[P1]\x1b[0m",
362 2 => "\x1b[33m[P2]\x1b[0m",
363 3 => "\x1b[34m[P3]\x1b[0m",
364 _ => "[P4+]",
365 }
366 } else {
367 match rec.priority {
368 1 => "[P1]",
369 2 => "[P2]",
370 3 => "[P3]",
371 _ => "[P4+]",
372 }
373 };
374 lines.push(format!(
375 " {} {} ({} affected, +{:.1} impact)",
376 priority_indicator, rec.message, rec.affected_count, rec.impact
377 ));
378 }
379 lines.push(String::new());
380 }
381
382 lines.join("\n")
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_parse_scoring_profile() {
391 assert!(matches!(
392 parse_scoring_profile("minimal").unwrap(),
393 ScoringProfile::Minimal
394 ));
395 assert!(matches!(
396 parse_scoring_profile("standard").unwrap(),
397 ScoringProfile::Standard
398 ));
399 assert!(matches!(
400 parse_scoring_profile("security").unwrap(),
401 ScoringProfile::Security
402 ));
403 assert!(matches!(
404 parse_scoring_profile("license-compliance").unwrap(),
405 ScoringProfile::LicenseCompliance
406 ));
407 assert!(matches!(
408 parse_scoring_profile("cra").unwrap(),
409 ScoringProfile::Cra
410 ));
411 assert!(matches!(
412 parse_scoring_profile("comprehensive").unwrap(),
413 ScoringProfile::Comprehensive
414 ));
415 }
416
417 #[test]
418 fn test_parse_scoring_profile_case_insensitive() {
419 assert!(matches!(
420 parse_scoring_profile("MINIMAL").unwrap(),
421 ScoringProfile::Minimal
422 ));
423 assert!(matches!(
424 parse_scoring_profile("Standard").unwrap(),
425 ScoringProfile::Standard
426 ));
427 }
428
429 #[test]
430 fn test_parse_scoring_profile_invalid() {
431 assert!(parse_scoring_profile("invalid").is_err());
432 }
433
434 #[test]
435 fn test_parse_scoring_profile_aliases() {
436 assert!(matches!(
437 parse_scoring_profile("license").unwrap(),
438 ScoringProfile::LicenseCompliance
439 ));
440 assert!(matches!(
441 parse_scoring_profile("full").unwrap(),
442 ScoringProfile::Comprehensive
443 ));
444 assert!(matches!(
445 parse_scoring_profile("cyber-resilience").unwrap(),
446 ScoringProfile::Cra
447 ));
448 }
449}