1use 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
13pub const QUALITY_OUTPUT_FORMATS: &[ReportFormat] = &[
18 ReportFormat::Auto,
19 ReportFormat::Summary,
20 ReportFormat::Json,
21 ReportFormat::Sarif,
22 ReportFormat::SbomqsJson,
23];
24
25pub 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 pub fail_on_noncompliant: bool,
38 pub no_color: bool,
39 pub cra_sidecar_path: Option<PathBuf>,
43 pub cra_product_class: Option<String>,
45 pub as_of: Option<String>,
50 pub enrichment: EnrichmentConfig,
54}
55
56#[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 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 #[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 let sidecar = super::load_cra_sidecar(config.cra_sidecar_path.as_deref(), &config.sbom_path)?;
141 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 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 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 let output_target = OutputTarget::from_option(config.output_file);
193 write_output(&output_text, &output_target, false)?;
194
195 let ai_not_applicable = report
199 .ai_readiness_metrics
200 .as_ref()
201 .is_some_and(crate::quality::AiReadinessMetrics::is_not_applicable);
202
203 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 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
238fn 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 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
268fn format_quality_sarif(report: &QualityReport, config: &QualityConfig) -> String {
270 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 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
312fn 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 if report.profile == ScoringProfile::AiReadiness {
328 return format_ai_readiness_report(report, config, use_color);
329 }
330
331 let (grade_color, reset) = if use_color {
333 let color = match report.grade {
334 QualityGrade::A | QualityGrade::B => "\x1b[32m", QualityGrade::C | QualityGrade::D => "\x1b[33m", QualityGrade::F => "\x1b[31m", };
338 (color, "\x1b[0m")
339 } else {
340 ("", "")
341 };
342
343 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 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 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 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 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 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 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 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
526fn 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 #[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 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 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 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 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), true, 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 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, 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 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 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 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 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 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 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 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}