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::config::EnrichmentConfig;
6use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
7use crate::quality::{QualityGrade, QualityReport, QualityScorer, ScoringProfile};
8use crate::reports::ReportFormat;
9use anyhow::Result;
10use serde_json::json;
11use std::path::PathBuf;
12
13/// Output formats the `quality` command has a real renderer for.
14/// `auto`/`summary` render the plain-text report; JSON, SARIF, and
15/// sbomqs-json are dedicated emitters. All other [`ReportFormat`] values are
16/// rejected up front instead of silently falling back to text.
17pub const QUALITY_OUTPUT_FORMATS: &[ReportFormat] = &[
18    ReportFormat::Auto,
19    ReportFormat::Summary,
20    ReportFormat::Json,
21    ReportFormat::Sarif,
22    ReportFormat::SbomqsJson,
23];
24
25/// Quality command configuration
26pub struct QualityConfig {
27    pub sbom_path: PathBuf,
28    pub profile: ScoringProfile,
29    pub output: ReportFormat,
30    pub output_file: Option<PathBuf>,
31    pub show_recommendations: bool,
32    pub show_metrics: bool,
33    pub min_score: Option<f32>,
34    /// Exit non-zero when the compliance verdict is non-compliant (opt-in;
35    /// the default gate is `--min-score` only, so existing scripts are
36    /// unaffected).
37    pub fail_on_noncompliant: bool,
38    pub no_color: bool,
39    /// Optional CRA sidecar metadata path (auto-discovered next to the SBOM
40    /// when None). Supplements the embedded compliance check used by the
41    /// `cra` scoring profile.
42    pub cra_sidecar_path: Option<PathBuf>,
43    /// CRA Annex III/IV product class (CLI string form). Sidecar value wins.
44    pub cra_product_class: Option<String>,
45    /// Pinned evaluation clock (raw `--as-of` CLI form). Deadline-sensitive
46    /// compliance checks embedded in the report (CRA Art. 14 readiness, SBOM
47    /// age, EUCC certificate expiry) evaluate against this instant instead of
48    /// the wall clock, mirroring `validate --as-of`.
49    pub as_of: Option<String>,
50    /// Enrichment configuration (OSV / KEV / EOL / staleness / VEX). When any
51    /// source is enabled the SBOM is enriched before scoring so the
52    /// Lifecycle / `VulnDocs` categories reflect live data.
53    pub enrichment: EnrichmentConfig,
54}
55
56/// Run the quality command, returning the desired exit code.
57///
58/// Gate codes (below-threshold / non-compliant) only apply to runs that
59/// completed an assessment; usage/configuration errors propagate as `Err`,
60/// which the binary's `main()` maps to process exit code 1.
61///
62/// The caller is responsible for calling `std::process::exit()` with the
63/// returned code when it is non-zero.
64#[allow(clippy::too_many_arguments)]
65pub fn run_quality(
66    sbom_path: PathBuf,
67    profile: ScoringProfile,
68    output: ReportFormat,
69    output_file: Option<PathBuf>,
70    show_recommendations: bool,
71    show_metrics: bool,
72    min_score: Option<f32>,
73    fail_on_noncompliant: bool,
74    no_color: bool,
75    cra_sidecar_path: Option<PathBuf>,
76    cra_product_class: Option<String>,
77    as_of: Option<String>,
78    enrichment: EnrichmentConfig,
79) -> Result<i32> {
80    let config = QualityConfig {
81        sbom_path,
82        profile,
83        output,
84        output_file,
85        show_recommendations,
86        show_metrics,
87        min_score,
88        fail_on_noncompliant,
89        no_color,
90        cra_sidecar_path,
91        cra_product_class,
92        as_of,
93        enrichment,
94    };
95
96    run_quality_impl(config)
97}
98
99fn run_quality_impl(config: QualityConfig) -> Result<i32> {
100    super::ensure_output_format_supported("quality", config.output, QUALITY_OUTPUT_FORMATS)?;
101
102    // Pinned evaluation clock for deadline-sensitive compliance checks
103    // (shared parser with `validate --as-of`). Parsed up front so a bad
104    // value fails before the SBOM is read.
105    let as_of: Option<chrono::DateTime<chrono::Utc>> = config
106        .as_of
107        .as_deref()
108        .map(super::parse_as_of)
109        .transpose()?;
110
111    #[cfg_attr(not(feature = "enrichment"), allow(unused_mut))]
112    let mut parsed = parse_sbom_with_context(&config.sbom_path, false)?;
113
114    // Enrich before scoring so Lifecycle (staleness/EOL) and VulnDocs (OSV/KEV)
115    // categories reflect live data rather than only the static SBOM contents.
116    #[cfg(feature = "enrichment")]
117    {
118        let any_enrichment = config.enrichment.enabled
119            || config.enrichment.enable_eol
120            || config.enrichment.enable_kev
121            || config.enrichment.enable_epss
122            || config.enrichment.enable_staleness
123            || config.enrichment.enable_huggingface
124            || !config.enrichment.vex_paths.is_empty();
125        if any_enrichment {
126            let stats =
127                crate::pipeline::enrich_sbom_full(parsed.sbom_mut(), &config.enrichment, false);
128            for warning in &stats.warnings {
129                tracing::warn!("{warning}");
130            }
131        }
132    }
133
134    let profile = config.profile;
135
136    tracing::info!("Running quality assessment with {:?} profile", profile);
137
138    // Honour explicit --cra-sidecar (hard error when broken); otherwise
139    // auto-discover next to the SBOM (best-effort).
140    let sidecar = super::load_cra_sidecar(config.cra_sidecar_path.as_deref(), &config.sbom_path)?;
141    // An explicitly passed unrecognized class is a hard error (strict parse).
142    let cli_class = super::parse_cra_product_class(config.cra_product_class.as_deref())?;
143    let sidecar_class = sidecar.as_ref().and_then(|s| s.product_class);
144    if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
145        && cli != side
146    {
147        tracing::warn!(
148            "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
149            cli.label(),
150            side.label()
151        );
152    }
153    let effective_class = sidecar_class.or(cli_class);
154
155    let mut scorer = QualityScorer::new(profile);
156    if let Some(sc) = sidecar {
157        scorer = scorer.with_cra_sidecar(sc);
158    }
159    if let Some(c) = effective_class {
160        scorer = scorer.with_cra_product_class(c);
161    }
162    if let Some(t) = as_of {
163        scorer = scorer.with_as_of(t);
164    }
165    let report = scorer.score(parsed.sbom());
166
167    // sbomqs-compat table for the human summary. Computed straight from the
168    // NormalizedSbom (plus the raw document text for file-format /
169    // data-license detection) — deliberately NOT from `report`: the
170    // 0-100 pipeline and the sbomqs 0-10 model are not convertible, and the
171    // compat path must never read `QualityReport.overall_score`.
172    // The AI-readiness profile has its own dedicated layout and skips it.
173    let sbomqs_input = crate::reports::sbomqs_compat::SbomqsCompatInput {
174        sbom: parsed.sbom(),
175        file_name: &config.sbom_path.to_string_lossy(),
176        raw_content: Some(parsed.raw_content()),
177    };
178
179    // Build output based on format
180    let output_text = match config.output {
181        ReportFormat::Json => format_quality_json(&report, &config),
182        ReportFormat::Sarif => format_quality_sarif(&report, &config),
183        ReportFormat::SbomqsJson => crate::reports::sbomqs_compat::render_json(&sbomqs_input),
184        _ => {
185            let sbomqs_table = (config.profile != ScoringProfile::AiReadiness)
186                .then(|| crate::reports::sbomqs_compat::render_summary_table(&sbomqs_input));
187            format_quality_report(&report, &config, sbomqs_table.as_deref())
188        }
189    };
190
191    // Write output
192    let output_target = OutputTarget::from_option(config.output_file);
193    write_output(&output_text, &output_target, false)?;
194
195    // An N/A AI-readiness report (no ML components) has no meaningful score
196    // and no rendered compliance verdict (text and SARIF both show "N/A"),
197    // so NEITHER gate below may fire on it.
198    let ai_not_applicable = report
199        .ai_readiness_metrics
200        .as_ref()
201        .is_some_and(crate::quality::AiReadinessMetrics::is_not_applicable);
202
203    // Check minimum score threshold.
204    if let Some(threshold) = config.min_score
205        && !ai_not_applicable
206        && report.overall_score < threshold
207    {
208        tracing::error!(
209            "Quality score {:.1} is below minimum threshold {:.1}",
210            report.overall_score,
211            threshold
212        );
213        return Ok(exit_codes::QUALITY_BELOW_THRESHOLD);
214    }
215
216    // Opt-in: fail the command when the compliance verdict is non-compliant,
217    // so the printed "NON-COMPLIANT" cannot be paired with a success exit.
218    // Off by default, so `quality --min-score` keeps its score-only contract.
219    // Guarded by the same applicability rules as the score gate: an N/A run
220    // (AI-readiness without ML components, or a not-applicable compliance
221    // standard) renders no verdict and must not flip the exit code.
222    if config.fail_on_noncompliant
223        && !ai_not_applicable
224        && report.compliance.is_applicable()
225        && !report.compliance.is_compliant
226    {
227        tracing::error!(
228            "SBOM is non-compliant with {} ({} error(s))",
229            report.compliance.level.name(),
230            report.compliance.error_count
231        );
232        return Ok(exit_codes::COMPLIANCE_ERRORS);
233    }
234
235    Ok(exit_codes::SUCCESS)
236}
237
238/// Format quality report as JSON
239fn format_quality_json(report: &QualityReport, config: &QualityConfig) -> String {
240    let not_applicable = report
241        .ai_readiness_metrics
242        .as_ref()
243        .is_some_and(crate::quality::AiReadinessMetrics::is_not_applicable);
244
245    // Serialize the report, then for an N/A AI-readiness result replace the
246    // overall_score/grade so machine consumers don't read a 0.0 / "F" as a real
247    // failing score (the standard 8-category pipeline did not run).
248    let mut report_value = serde_json::to_value(report).unwrap_or_default();
249    if not_applicable && let Some(obj) = report_value.as_object_mut() {
250        obj.insert("overall_score".to_string(), serde_json::Value::Null);
251        obj.insert(
252            "grade".to_string(),
253            serde_json::Value::String("N/A".to_string()),
254        );
255    }
256
257    let output = json!({
258        "tool": "sbom-tools",
259        "version": env!("CARGO_PKG_VERSION"),
260        "sbom": config.sbom_path.file_name().unwrap_or_default().to_string_lossy(),
261        "profile": config.profile.to_string(),
262        "applicable": !not_applicable,
263        "report": report_value,
264    });
265    serde_json::to_string_pretty(&output).unwrap_or_default()
266}
267
268/// Format quality report as SARIF 2.1.0
269fn format_quality_sarif(report: &QualityReport, config: &QualityConfig) -> String {
270    // AI-readiness uses a dedicated SBOM-AIBOM-* SARIF rule family (one result per
271    // failing model-card check), with a rule table and run-level properties.
272    if report.profile == ScoringProfile::AiReadiness
273        && let Some(metrics) = report.ai_readiness_metrics.as_ref()
274    {
275        let na = metrics.is_not_applicable();
276        let score = if na { None } else { Some(report.overall_score) };
277        let grade = if na { "N/A" } else { report.grade.letter() };
278        return crate::reports::generate_ai_readiness_sarif(
279            metrics,
280            &config
281                .sbom_path
282                .file_name()
283                .unwrap_or_default()
284                .to_string_lossy(),
285            &config.profile.to_string(),
286            score,
287            grade,
288        )
289        .unwrap_or_else(|_| {
290            serde_json::to_string_pretty(&serde_json::json!({ "runs": [] })).unwrap_or_default()
291        });
292    }
293
294    // Everything else routes through the shared registry-driven SARIF layer:
295    // the compliance violations carry the exact same external rule ids as
296    // `validate -o sarif`, and recommendations are merged into the same run
297    // as advisory (never `error`) results.
298    crate::reports::generate_quality_sarif(
299        report,
300        &config
301            .sbom_path
302            .file_name()
303            .unwrap_or_default()
304            .to_string_lossy(),
305        &config.profile.to_string(),
306    )
307    .unwrap_or_else(|_| {
308        serde_json::to_string_pretty(&serde_json::json!({ "runs": [] })).unwrap_or_default()
309    })
310}
311
312/// Format quality report for output.
313///
314/// `sbomqs_table` is the pre-rendered sbomqs-comparable score table (0-10),
315/// appended verbatim at the end of the report. `None` for profiles with a
316/// dedicated layout (AI-readiness) and for direct test callers.
317fn format_quality_report(
318    report: &QualityReport,
319    config: &QualityConfig,
320    sbomqs_table: Option<&str>,
321) -> String {
322    let mut lines = Vec::new();
323    let use_color = !config.no_color && std::env::var("NO_COLOR").is_err();
324
325    // AI-readiness uses a dedicated report layout (per-check pass/fail, not the
326    // standard 8 category scores).
327    if report.profile == ScoringProfile::AiReadiness {
328        return format_ai_readiness_report(report, config, use_color);
329    }
330
331    // Color codes
332    let (grade_color, reset) = if use_color {
333        let color = match report.grade {
334            QualityGrade::A | QualityGrade::B => "\x1b[32m", // Green
335            QualityGrade::C | QualityGrade::D => "\x1b[33m", // Yellow
336            QualityGrade::F => "\x1b[31m",                   // Red
337        };
338        (color, "\x1b[0m")
339    } else {
340        ("", "")
341    };
342
343    // Header
344    lines.push(format!(
345        "SBOM Quality Report: {}",
346        config
347            .sbom_path
348            .file_name()
349            .unwrap_or_default()
350            .to_string_lossy()
351    ));
352    lines.push(format!("Profile: {}", config.profile));
353    lines.push(String::new());
354
355    // Overall score
356    lines.push(format!(
357        "Overall Score: {}{:.1}/100 (Grade: {}){}",
358        grade_color,
359        report.overall_score,
360        report.grade.letter(),
361        reset
362    ));
363    lines.push(String::new());
364
365    // Category scores
366    lines.push("Category Scores:".to_string());
367    lines.push(format!(
368        "  Completeness:    {:.1}/100",
369        report.completeness_score
370    ));
371    lines.push(format!(
372        "  Identifiers:     {:.1}/100",
373        report.identifier_score
374    ));
375    lines.push(format!(
376        "  Licenses:        {:.1}/100",
377        report.license_score
378    ));
379    lines.push(match report.vulnerability_score {
380        Some(score) => format!("  Vulnerabilities: {score:.1}/100"),
381        None => "  Vulnerabilities: N/A".to_string(),
382    });
383    lines.push(format!(
384        "  Dependencies:    {:.1}/100",
385        report.dependency_score
386    ));
387    lines.push(String::new());
388
389    // Compliance status
390    let compliance_status = if report.compliance.is_compliant {
391        format!(
392            "{}COMPLIANT{}",
393            if use_color { "\x1b[32m" } else { "" },
394            reset
395        )
396    } else {
397        format!(
398            "{}NON-COMPLIANT{}",
399            if use_color { "\x1b[31m" } else { "" },
400            reset
401        )
402    };
403    lines.push(format!(
404        "Compliance ({}): {} ({} errors, {} warnings)",
405        report.compliance.level.name(),
406        compliance_status,
407        report.compliance.error_count,
408        report.compliance.warning_count
409    ));
410    lines.push(String::new());
411
412    // Detailed metrics
413    if config.show_metrics {
414        lines.push("Detailed Metrics:".to_string());
415        lines.push(format!(
416            "  Total Components: {}",
417            report.completeness_metrics.total_components
418        ));
419        lines.push(format!(
420            "  With Version:     {:.1}%",
421            report.completeness_metrics.components_with_version
422        ));
423        lines.push(format!(
424            "  With PURL:        {:.1}%",
425            report.completeness_metrics.components_with_purl
426        ));
427        lines.push(format!(
428            "  With License:     {:.1}%",
429            report.completeness_metrics.components_with_licenses
430        ));
431        lines.push(format!(
432            "  With Supplier:    {:.1}%",
433            report.completeness_metrics.components_with_supplier
434        ));
435        lines.push(format!(
436            "  With Hashes:      {:.1}%",
437            report.completeness_metrics.components_with_hashes
438        ));
439        lines.push(String::new());
440
441        lines.push("  Identifier Quality:".to_string());
442        lines.push(format!(
443            "    Valid PURLs:    {}",
444            report.identifier_metrics.valid_purls
445        ));
446        lines.push(format!(
447            "    Valid CPEs:     {}",
448            report.identifier_metrics.valid_cpes
449        ));
450        lines.push(format!(
451            "    Missing IDs:    {}",
452            report.identifier_metrics.missing_all_identifiers
453        ));
454        lines.push(format!(
455            "    Ecosystems:     {}",
456            report.identifier_metrics.ecosystems.join(", ")
457        ));
458        lines.push(String::new());
459
460        lines.push("  Dependency Graph:".to_string());
461        lines.push(format!(
462            "    Total Edges:    {}",
463            report.dependency_metrics.total_dependencies
464        ));
465        lines.push(format!(
466            "    Orphan Nodes:   {}",
467            report.dependency_metrics.orphan_components
468        ));
469        // Software complexity index
470        if let Some(simplicity) = report.dependency_metrics.software_complexity_index {
471            let level = report
472                .dependency_metrics
473                .complexity_level
474                .as_ref()
475                .map_or("N/A", |l| l.label());
476            lines.push(format!("    Complexity:     {simplicity:.0}/100 ({level})"));
477            if let Some(ref f) = report.dependency_metrics.complexity_factors {
478                lines.push(format!(
479                    "      Volume: {:.2}  Depth: {:.2}  Fanout: {:.2}  Cycles: {:.2}  Fragmentation: {:.2}",
480                    f.dependency_volume, f.normalized_depth, f.fanout_concentration, f.cycle_ratio, f.fragmentation
481                ));
482            }
483        } else {
484            lines.push("    Complexity:     N/A (graph analysis skipped)".to_string());
485        }
486        lines.push(String::new());
487    }
488
489    // Recommendations
490    if config.show_recommendations && !report.recommendations.is_empty() {
491        lines.push("Recommendations:".to_string());
492        for rec in report.recommendations.iter().take(10) {
493            let priority_indicator = if use_color {
494                match rec.priority {
495                    1 => "\x1b[31m[P1]\x1b[0m",
496                    2 => "\x1b[33m[P2]\x1b[0m",
497                    3 => "\x1b[34m[P3]\x1b[0m",
498                    _ => "[P4+]",
499                }
500            } else {
501                match rec.priority {
502                    1 => "[P1]",
503                    2 => "[P2]",
504                    3 => "[P3]",
505                    _ => "[P4+]",
506                }
507            };
508            lines.push(format!(
509                "  {} {} ({} affected, +{:.1} impact)",
510                priority_indicator, rec.message, rec.affected_count, rec.impact
511            ));
512        }
513        lines.push(String::new());
514    }
515
516    // Compact sbomqs-comparable table (0-10 scores recomputed per-feature
517    // with sbomqs' formulas — never overall_score/10).
518    if let Some(table) = sbomqs_table {
519        lines.push(table.to_string());
520        lines.push(String::new());
521    }
522
523    lines.join("\n")
524}
525
526/// Render the AI-readiness profile as a per-check pass/fail report.
527fn format_ai_readiness_report(
528    report: &QualityReport,
529    config: &QualityConfig,
530    use_color: bool,
531) -> String {
532    let mut lines = Vec::new();
533    let Some(metrics) = report.ai_readiness_metrics.as_ref() else {
534        return String::new();
535    };
536    let reset = if use_color { "\x1b[0m" } else { "" };
537
538    lines.push(format!(
539        "SBOM Quality Report: {}",
540        config
541            .sbom_path
542            .file_name()
543            .unwrap_or_default()
544            .to_string_lossy()
545    ));
546    lines.push(format!("Profile: {}", config.profile));
547    lines.push(String::new());
548
549    if metrics.is_not_applicable() {
550        let muted = if use_color { "\x1b[33m" } else { "" };
551        lines.push(format!("Overall Score: {muted}N/A{reset}"));
552        lines.push(
553            metrics
554                .na_reason
555                .clone()
556                .unwrap_or_else(|| "AI readiness is not applicable for this SBOM".to_string()),
557        );
558        return lines.join("\n");
559    }
560
561    let grade_color = if use_color {
562        match report.grade {
563            QualityGrade::A | QualityGrade::B => "\x1b[32m",
564            QualityGrade::C | QualityGrade::D => "\x1b[33m",
565            QualityGrade::F => "\x1b[31m",
566        }
567    } else {
568        ""
569    };
570    lines.push(format!(
571        "Overall Score: {}{:.1}/100 (Grade: {}){}",
572        grade_color,
573        report.overall_score,
574        report.grade.letter(),
575        reset
576    ));
577    lines.push(format!(
578        "ML Components: {} total, {} fully documented",
579        metrics.ml_component_count, metrics.components_fully_documented
580    ));
581    lines.push(String::new());
582    lines.push("AI Readiness Checks:".to_string());
583
584    for check in &metrics.checks {
585        let status = if check.passed { "PASS" } else { "FAIL" };
586        let status_color = if use_color {
587            if check.passed { "\x1b[32m" } else { "\x1b[31m" }
588        } else {
589            ""
590        };
591        lines.push(format!(
592            "  {}{}{} {} ({:.0}%)",
593            status_color,
594            status,
595            reset,
596            check.id,
597            check.weight * 100.0
598        ));
599        lines.push(format!("    {}", check.name));
600        if config.show_metrics
601            && let Some(detail) = &check.detail
602        {
603            lines.push(format!("    {detail}"));
604        }
605    }
606    lines.push(String::new());
607
608    if config.show_recommendations && !report.recommendations.is_empty() {
609        lines.push("Recommendations:".to_string());
610        for rec in report.recommendations.iter().take(10) {
611            lines.push(format!(
612                "  [P{}] {} ({} affected, +{:.1} impact)",
613                rec.priority, rec.message, rec.affected_count, rec.impact
614            ));
615        }
616        lines.push(String::new());
617    }
618
619    lines.join("\n")
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::model::{Component, ComponentType, DocumentMetadata, MlModelInfo, NormalizedSbom};
626
627    /// Contract test: every documented `--profile` spelling parses to the
628    /// right profile through the single shared parser (clap uses the same
629    /// name/alias table).
630    #[test]
631    fn every_documented_profile_alias_parses() {
632        let table: &[(&str, ScoringProfile)] = &[
633            ("minimal", ScoringProfile::Minimal),
634            ("standard", ScoringProfile::Standard),
635            ("security", ScoringProfile::Security),
636            ("license-compliance", ScoringProfile::LicenseCompliance),
637            ("license", ScoringProfile::LicenseCompliance),
638            ("cra", ScoringProfile::Cra),
639            ("cyber-resilience", ScoringProfile::Cra),
640            ("bsi", ScoringProfile::BsiTr03183_2),
641            ("tr-03183", ScoringProfile::BsiTr03183_2),
642            ("tr03183", ScoringProfile::BsiTr03183_2),
643            ("bsi-tr-03183-2", ScoringProfile::BsiTr03183_2),
644            ("comprehensive", ScoringProfile::Comprehensive),
645            ("full", ScoringProfile::Comprehensive),
646            ("cbom", ScoringProfile::Cbom),
647            ("cryptographic", ScoringProfile::Cbom),
648            ("ai-readiness", ScoringProfile::AiReadiness),
649            ("ai_readiness", ScoringProfile::AiReadiness),
650        ];
651        for (spelling, expected) in table {
652            let parsed: ScoringProfile = spelling
653                .parse()
654                .unwrap_or_else(|e| panic!("'{spelling}' must parse: {e}"));
655            assert_eq!(parsed, *expected, "'{spelling}' mapped to wrong profile");
656        }
657    }
658
659    #[test]
660    fn profile_parse_is_case_insensitive_and_rejects_unknown() {
661        assert_eq!(
662            "MINIMAL".parse::<ScoringProfile>().unwrap(),
663            ScoringProfile::Minimal
664        );
665        assert_eq!(
666            "Standard".parse::<ScoringProfile>().unwrap(),
667            ScoringProfile::Standard
668        );
669        let err = "invalid".parse::<ScoringProfile>().unwrap_err();
670        assert!(err.contains("Valid values"));
671        assert!(err.contains("license-compliance"));
672    }
673
674    #[test]
675    fn rejects_unsupported_output_format_before_reading_sbom() {
676        // html/markdown/csv/oscal-json used to fall through to the text
677        // renderer; they must now fail fast (before the SBOM is read — the
678        // path here does not exist).
679        for format in [
680            ReportFormat::Html,
681            ReportFormat::Markdown,
682            ReportFormat::Csv,
683            ReportFormat::OscalJson,
684            ReportFormat::Ndjson,
685            ReportFormat::Table,
686            ReportFormat::SideBySide,
687            ReportFormat::Tui,
688        ] {
689            let err = run_quality(
690                PathBuf::from("/nonexistent/never-read.cdx.json"),
691                ScoringProfile::Standard,
692                format,
693                None,
694                false,
695                false,
696                None,
697                false,
698                true,
699                None,
700                None,
701                None,
702                EnrichmentConfig::default(),
703            )
704            .expect_err("unsupported format must be rejected");
705            let msg = err.to_string();
706            assert!(
707                msg.contains("not supported by `sbom-tools quality`"),
708                "unexpected error for {format}: {msg}"
709            );
710            assert!(
711                msg.contains("sarif") && msg.contains("json"),
712                "error must list the supported formats: {msg}"
713            );
714        }
715    }
716
717    #[test]
718    fn sbomqs_json_output_emits_sbomqs_shaped_report() {
719        let dir = tempfile::tempdir().unwrap();
720        let sbom_path = dir.path().join("app.cdx.json");
721        std::fs::write(
722            &sbom_path,
723            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,
724                "components":[{"type":"library","name":"lodash","version":"4.17.21",
725                               "purl":"pkg:npm/lodash@4.17.21"}]}"#,
726        )
727        .unwrap();
728        let out_path = dir.path().join("out.json");
729        let code = run_quality(
730            sbom_path,
731            ScoringProfile::Standard,
732            ReportFormat::SbomqsJson,
733            Some(out_path.clone()),
734            false,
735            false,
736            None,
737            false,
738            true,
739            None,
740            None,
741            None,
742            EnrichmentConfig::default(),
743        )
744        .expect("sbomqs-json run must succeed");
745        assert_eq!(code, exit_codes::SUCCESS);
746
747        let value: serde_json::Value =
748            serde_json::from_str(&std::fs::read_to_string(&out_path).unwrap())
749                .expect("sbomqs-json output must be valid JSON");
750        // Exact sbomqs score-report shape, honest identity.
751        assert!(value["run_id"].is_string());
752        assert_eq!(value["creation_info"]["name"], "sbom-tools");
753        let file = &value["files"][0];
754        assert_eq!(file["spec"], "cyclonedx");
755        assert_eq!(file["file_format"], "json");
756        assert!(file["avg_score"].is_number());
757        let scores = file["scores"].as_array().expect("scores array");
758        assert_eq!(scores.len(), 23);
759        assert!(
760            scores
761                .iter()
762                .any(|s| s["feature"] == "comp_with_name" && s["score"] == 10.0)
763        );
764    }
765
766    #[test]
767    fn summary_report_appends_sbomqs_compat_table() {
768        let dir = tempfile::tempdir().unwrap();
769        let sbom_path = write_minimal_sbom(dir.path());
770        let out_path = dir.path().join("out.txt");
771        let code = run_quality(
772            sbom_path,
773            ScoringProfile::Standard,
774            ReportFormat::Summary,
775            Some(out_path.clone()),
776            false,
777            false,
778            None,
779            false,
780            true,
781            None,
782            None,
783            None,
784            EnrichmentConfig::default(),
785        )
786        .expect("summary run must succeed");
787        assert_eq!(code, exit_codes::SUCCESS);
788        let text = std::fs::read_to_string(&out_path).unwrap();
789        assert!(text.contains("sbomqs-Comparable Scores"));
790        assert!(text.contains("NTIA-minimum-elements"));
791        assert!(
792            text.contains("not convertible"),
793            "table must carry the non-convertibility note"
794        );
795    }
796
797    #[test]
798    fn explicit_missing_sidecar_is_a_hard_error() {
799        // An explicitly passed --cra-sidecar that fails to load used to be
800        // silently ignored (`.ok()`); it must now abort the command.
801        let dir = tempfile::tempdir().unwrap();
802        let sbom_path = dir.path().join("app.cdx.json");
803        std::fs::write(
804            &sbom_path,
805            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
806        )
807        .unwrap();
808        let err = run_quality(
809            sbom_path,
810            ScoringProfile::Cra,
811            ReportFormat::Summary,
812            None,
813            false,
814            false,
815            None,
816            false,
817            true,
818            Some(dir.path().join("missing.cra.json")),
819            None,
820            None,
821            EnrichmentConfig::default(),
822        )
823        .expect_err("broken explicit sidecar must be a hard error");
824        assert!(err.to_string().contains("Failed to load CRA sidecar"));
825    }
826
827    fn write_minimal_sbom(dir: &std::path::Path) -> PathBuf {
828        let sbom_path = dir.join("app.cdx.json");
829        std::fs::write(
830            &sbom_path,
831            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
832        )
833        .unwrap();
834        sbom_path
835    }
836
837    #[test]
838    fn fail_on_noncompliant_does_not_fire_on_na_ai_readiness_run() {
839        // Regression: an N/A AI-readiness run (no ML components) used to
840        // exit 1 on the hidden Comprehensive-level compliance check that no
841        // renderer ever displays. Both gates are armed here; neither may fire.
842        let dir = tempfile::tempdir().unwrap();
843        let sbom_path = write_minimal_sbom(dir.path());
844        let code = run_quality(
845            sbom_path,
846            ScoringProfile::AiReadiness,
847            ReportFormat::Summary,
848            Some(dir.path().join("out.txt")),
849            false,
850            false,
851            Some(70.0), // --min-score: must not fire on N/A either (P0 guard)
852            true,       // --fail-on-noncompliant
853            true,
854            None,
855            None,
856            None,
857            EnrichmentConfig::default(),
858        )
859        .expect("an N/A AI-readiness run must not error");
860        assert_eq!(
861            code,
862            exit_codes::SUCCESS,
863            "N/A AI-readiness run must exit 0 with --fail-on-noncompliant"
864        );
865    }
866
867    #[test]
868    fn fail_on_noncompliant_still_fires_on_applicable_noncompliant_run() {
869        // The empty SBOM is genuinely non-compliant with the CRA profile's
870        // embedded check, so the opt-in gate must still flip the exit code.
871        let dir = tempfile::tempdir().unwrap();
872        let sbom_path = write_minimal_sbom(dir.path());
873        let code = run_quality(
874            sbom_path,
875            ScoringProfile::Cra,
876            ReportFormat::Summary,
877            Some(dir.path().join("out.txt")),
878            false,
879            false,
880            None,
881            true, // --fail-on-noncompliant
882            true,
883            None,
884            None,
885            None,
886            EnrichmentConfig::default(),
887        )
888        .expect("the run itself must succeed");
889        assert_eq!(code, exit_codes::COMPLIANCE_ERRORS);
890    }
891
892    #[test]
893    fn typod_cra_product_class_is_a_hard_error() {
894        // Regression: a typo'd --cra-product-class was silently dropped,
895        // scoring a critical-class product as Default.
896        let dir = tempfile::tempdir().unwrap();
897        let sbom_path = write_minimal_sbom(dir.path());
898        let err = run_quality(
899            sbom_path,
900            ScoringProfile::Cra,
901            ReportFormat::Summary,
902            None,
903            false,
904            false,
905            None,
906            false,
907            true,
908            None,
909            Some("critcal".to_string()),
910            None,
911            EnrichmentConfig::default(),
912        )
913        .expect_err("typo'd --cra-product-class must be a hard error");
914        let msg = err.to_string();
915        assert!(msg.contains("critcal"), "must name the bad value: {msg}");
916        assert!(
917            msg.contains("critical"),
918            "must list the valid values: {msg}"
919        );
920    }
921
922    #[test]
923    fn invalid_as_of_is_a_hard_error_before_reading_the_sbom() {
924        let err = run_quality(
925            PathBuf::from("/nonexistent/never-read.cdx.json"),
926            ScoringProfile::Cra,
927            ReportFormat::Summary,
928            None,
929            false,
930            false,
931            None,
932            false,
933            true,
934            None,
935            None,
936            Some("not-a-date".to_string()),
937            EnrichmentConfig::default(),
938        )
939        .expect_err("invalid --as-of must be rejected");
940        assert!(err.to_string().contains("invalid --as-of"));
941    }
942
943    #[test]
944    fn auto_discovered_broken_sidecar_hard_fails() {
945        // A discovered-but-broken sidecar is a hard error, matching the
946        // explicit --cra-sidecar contract: silently scoring without it would
947        // shift the CRA verdict with only a stderr warning.
948        let dir = tempfile::tempdir().unwrap();
949        let sbom_path = dir.path().join("app.cdx.json");
950        std::fs::write(
951            &sbom_path,
952            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
953        )
954        .unwrap();
955        std::fs::write(dir.path().join("app.cra.json"), "{ not json").unwrap();
956        let err = run_quality(
957            sbom_path,
958            ScoringProfile::Cra,
959            ReportFormat::Json,
960            Some(dir.path().join("out.json")),
961            false,
962            false,
963            None,
964            false,
965            true,
966            None,
967            None,
968            None,
969            EnrichmentConfig::default(),
970        )
971        .expect_err("a discovered-but-broken sidecar must hard-error");
972        assert!(
973            err.to_string().contains("CRA sidecar"),
974            "error must name the sidecar: {err}"
975        );
976    }
977
978    fn ai_config(output: ReportFormat, min_score: Option<f32>) -> QualityConfig {
979        QualityConfig {
980            sbom_path: PathBuf::from("model.cdx.json"),
981            profile: ScoringProfile::AiReadiness,
982            output,
983            output_file: None,
984            show_recommendations: true,
985            show_metrics: true,
986            min_score,
987            fail_on_noncompliant: false,
988            no_color: true,
989            cra_sidecar_path: None,
990            cra_product_class: None,
991            as_of: None,
992            enrichment: EnrichmentConfig::default(),
993        }
994    }
995
996    fn fully_documented_ml_sbom() -> NormalizedSbom {
997        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
998        let mut component = Component::new("bert-base".to_string(), "ml-model-1".to_string())
999            .with_version("1.0.0".to_string());
1000        component.component_type = ComponentType::MachineLearningModel;
1001        component.ml_model = Some(MlModelInfo {
1002            architecture_family: Some("transformer".to_string()),
1003            training_datasets: vec![crate::model::DatasetRef {
1004                reference: None,
1005                name: Some("dataset".to_string()),
1006                purl: None,
1007            }],
1008            energy_kwh_training: Some(20.0),
1009            model_card_url: Some("https://example.test/model-card".to_string()),
1010            limitations: Some("Only validated for English text".to_string()),
1011            ..MlModelInfo::default()
1012        });
1013        // A weight hash satisfies the AI-010 integrity check.
1014        component.hashes.push(crate::model::Hash::new(
1015            crate::model::HashAlgorithm::Sha256,
1016            "d".repeat(64),
1017        ));
1018        component.extensions.raw = Some(json!({
1019            "mlModel": { "modelCard": {
1020                "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.97 }] },
1021                "considerations": {
1022                    "fairnessConsiderations": ["Reviewed"],
1023                    "useCases": ["Classification"],
1024                    "ethicalConsiderations": ["Human review required"]
1025                }
1026            }}
1027        }));
1028        sbom.add_component(component);
1029        sbom
1030    }
1031
1032    #[test]
1033    fn test_format_quality_report_ai_readiness_shows_checks() {
1034        let sbom = fully_documented_ml_sbom();
1035        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1036        let output = format_quality_report(&report, &ai_config(ReportFormat::Summary, None), None);
1037        assert!(output.contains("AI Readiness Checks:"));
1038        assert!(output.contains("PASS AI-001"));
1039        assert!(!output.contains("Category Scores:"));
1040    }
1041
1042    #[test]
1043    fn test_format_quality_report_ai_readiness_na_shows_na() {
1044        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1045        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1046        let output =
1047            format_quality_report(&report, &ai_config(ReportFormat::Summary, Some(70.0)), None);
1048        assert!(output.contains("Overall Score: N/A"));
1049        assert!(output.contains("No machine-learning-model components found"));
1050    }
1051
1052    #[test]
1053    fn test_format_quality_json_ai_readiness_na_is_not_misleading() {
1054        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1055        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1056        let out = format_quality_json(&report, &ai_config(ReportFormat::Json, None));
1057        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1058        // N/A must not serialize as a real 0.0 / "F" score.
1059        assert_eq!(value["applicable"], json!(false));
1060        assert!(value["report"]["overall_score"].is_null());
1061        assert_eq!(value["report"]["grade"], json!("N/A"));
1062    }
1063
1064    #[test]
1065    fn test_format_quality_sarif_routes_compliance_through_registry_rule_ids() {
1066        // Non-AI profiles route through the shared SARIF layer: violations
1067        // carry registry SARIF rule ids (never invented QUALITY-* ids) and
1068        // every emitted ruleId has a reportingDescriptor.
1069        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1070        let report = QualityScorer::new(ScoringProfile::Cra).score(&sbom);
1071        let mut config = ai_config(ReportFormat::Sarif, None);
1072        config.profile = ScoringProfile::Cra;
1073        let out = format_quality_sarif(&report, &config);
1074        let value: serde_json::Value = serde_json::from_str(&out).expect("valid SARIF JSON");
1075        let run = &value["runs"][0];
1076        let results = run["results"].as_array().expect("results array");
1077        assert!(!results.is_empty(), "empty SBOM must fire CRA violations");
1078        assert!(
1079            results.iter().all(|r| r["ruleId"]
1080                .as_str()
1081                .is_some_and(|id| id.starts_with("SBOM-"))),
1082            "quality SARIF must not invent QUALITY-* rule ids"
1083        );
1084        assert!(
1085            run["tool"]["driver"]["rules"]
1086                .as_array()
1087                .is_some_and(|rules| !rules.is_empty()),
1088            "quality SARIF must declare its rule catalogue"
1089        );
1090        assert_eq!(run["properties"]["compliant"], json!(false));
1091    }
1092
1093    #[test]
1094    fn test_format_quality_sarif_ai_readiness_na_is_not_misleading() {
1095        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1096        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1097        let out = format_quality_sarif(&report, &ai_config(ReportFormat::Sarif, None));
1098        let value: serde_json::Value = serde_json::from_str(&out).expect("valid SARIF JSON");
1099        let run = &value["runs"][0];
1100        let props = &run["properties"];
1101        assert_eq!(props["applicable"], json!(false));
1102        // The serialized key is camelCase and *omitted* for the unscored N/A
1103        // case — indexing `props["overall_score"]` would return Null for any
1104        // absent key, so assert on the real key's absence.
1105        assert!(
1106            props.get("overallScore").is_none(),
1107            "unscored N/A run must omit overallScore entirely"
1108        );
1109        assert!(
1110            props["notApplicableReason"]
1111                .as_str()
1112                .is_some_and(|r| r.contains("No machine-learning-model components")),
1113            "N/A run must carry the metrics' human-readable reason"
1114        );
1115        assert_eq!(props["grade"], json!("N/A"));
1116        // The dedicated SBOM-AIBOM-* rule family is now emitted (was absent before),
1117        // and N/A yields no findings.
1118        let rules = run["tool"]["driver"]["rules"]
1119            .as_array()
1120            .expect("rules array");
1121        assert!(
1122            rules.iter().any(|r| r["id"] == json!("SBOM-AIBOM-001")),
1123            "expected SBOM-AIBOM rule table"
1124        );
1125        assert!(run["results"].as_array().expect("results array").is_empty());
1126    }
1127}