Skip to main content

sbom_tools/cli/
quality.rs

1//! Quality command handler.
2//!
3//! Implements the `quality` subcommand for assessing SBOM quality.
4
5use 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
14/// Quality command configuration
15pub 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/// Run the quality command, returning the desired exit code.
27///
28/// The caller is responsible for calling `std::process::exit()` with the
29/// returned code when it is non-zero.
30#[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    // Parse scoring profile
59    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    // Build output based on format
67    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    // Write output
74    let output_target = OutputTarget::from_option(config.output_file);
75    write_output(&output_text, &output_target, false)?;
76
77    // Check minimum score threshold
78    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
92/// Parse scoring profile from string
93fn 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
109/// Format quality report as JSON
110fn 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
121/// Format quality report as SARIF 2.1.0
122fn format_quality_sarif(report: &QualityReport, config: &QualityConfig) -> String {
123    let mut results = Vec::new();
124
125    // Add compliance violations as SARIF results
126    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    // Add recommendations as informational results
146    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
192/// Format quality report for output
193fn 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    // Color codes
198    let (grade_color, reset) = if use_color {
199        let color = match report.grade {
200            QualityGrade::A | QualityGrade::B => "\x1b[32m", // Green
201            QualityGrade::C | QualityGrade::D => "\x1b[33m", // Yellow
202            QualityGrade::F => "\x1b[31m",                   // Red
203        };
204        (color, "\x1b[0m")
205    } else {
206        ("", "")
207    };
208
209    // Header
210    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    // Overall score
222    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    // Category scores
232    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    // Compliance status
256    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    // Detailed metrics
279    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        // Software complexity index
336        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    // Recommendations
356    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}