use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
fn fixture_path(name: &str) -> PathBuf {
Path::new(FIXTURES_DIR).join(name)
}
fn base_command() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sbom-tools"));
cmd.arg("--no-color");
cmd.env("RUST_LOG", "error");
cmd.env("RUST_LOG_STYLE", "never");
cmd
}
fn stdout(output: &Output) -> String {
String::from_utf8(output.stdout.clone()).expect("stdout should be utf-8")
}
fn stderr(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).expect("stderr should be utf-8")
}
#[test]
fn validate_fail_on_warning_returns_exit_code_2() {
let output = base_command()
.arg("validate")
.arg(fixture_path("cyclonedx/ntia-warnings-only.cdx.json"))
.args(["--standard", "ntia", "--summary", "--fail-on-warning"])
.output()
.expect("validate command should run");
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
}
#[test]
fn validate_without_fail_on_warning_returns_exit_code_0_when_warnings_only() {
let output = base_command()
.arg("validate")
.arg(fixture_path("cyclonedx/ntia-warnings-only.cdx.json"))
.args(["--standard", "ntia", "--summary"])
.output()
.expect("validate command should run");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
}
#[test]
fn validate_with_errors_returns_exit_code_1() {
let output = base_command()
.arg("validate")
.arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
.args(["--standard", "ntia", "--summary"])
.output()
.expect("validate command should run");
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
}
#[test]
fn quality_below_min_score_returns_exit_code_1() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--min-score", "100", "-o", "json"])
.output()
.expect("quality command should run");
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
}
#[test]
fn quality_meeting_min_score_returns_exit_code_0() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--min-score", "0", "-o", "json"])
.output()
.expect("quality command should run");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
}
#[test]
fn query_no_match_returns_exit_code_1() {
let output = base_command()
.arg("query")
.arg("zzz-no-such-component-zzz")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "json"])
.output()
.expect("query command should run");
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
}
#[test]
fn query_match_returns_exit_code_0() {
let output = base_command()
.arg("query")
.arg("lodash")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "json"])
.output()
.expect("query command should run");
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
}
#[test]
fn invalid_fuzzy_preset_is_rejected_at_parse() {
let output = base_command()
.arg("diff")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["--fuzzy-preset", "not-a-preset"])
.output()
.expect("diff command should run");
assert_eq!(output.status.code(), Some(2), "{}", stdout(&output));
let err = stderr(&output);
assert!(
err.contains("invalid value 'not-a-preset'"),
"stderr should explain the invalid value: {err}"
);
assert!(
err.contains("possible values"),
"stderr should list the valid presets (did-you-mean hint): {err}"
);
}
#[test]
fn invalid_merge_dedup_strategy_is_rejected_at_parse() {
let output = base_command()
.arg("merge")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["--dedup", "not-a-strategy"])
.output()
.expect("merge command should run");
assert_eq!(output.status.code(), Some(2), "{}", stdout(&output));
let err = stderr(&output);
assert!(
err.contains("invalid value 'not-a-strategy'"),
"stderr should explain the invalid value: {err}"
);
assert!(
err.contains("name") && err.contains("purl"),
"stderr should list valid dedup strategies: {err}"
);
}
#[test]
fn diff_ndjson_output_emits_one_json_object_per_line() {
let output = base_command()
.arg("diff")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "ndjson"])
.output()
.expect("diff command should run");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(lines.len() >= 2, "expected multiple NDJSON lines: {text}");
for line in &lines {
let value: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("line is not valid json: {line}: {e}"));
assert!(
value.is_object(),
"each NDJSON line should be an object: {line}"
);
}
let first: serde_json::Value = serde_json::from_str(lines[0]).expect("first line json");
assert_eq!(first["type"], "metadata");
assert!(
lines
.iter()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.any(|v| v["type"] == "summary"),
"expected a summary NDJSON record: {text}"
);
}
#[test]
fn validate_help_enumerates_every_canonical_standard() {
let output = base_command()
.args(["validate", "--help"])
.output()
.expect("validate --help should run");
assert!(output.status.success(), "{}", stderr(&output));
let help = stdout(&output);
for canonical in [
"ntia",
"fda",
"cra",
"cra-phase1",
"ssdf",
"eo14028",
"cnsa2",
"pqc",
"bsi",
"oss-steward",
"eucc",
"ai-act",
"bsi-ai",
"cisa-2026",
"pci-dss",
"fsct",
] {
assert!(
help.contains(canonical),
"validate --help must list standard '{canonical}':\n{help}"
);
}
}
#[test]
fn quality_help_enumerates_every_canonical_profile() {
let output = base_command()
.args(["quality", "--help"])
.output()
.expect("quality --help should run");
assert!(output.status.success(), "{}", stderr(&output));
let help = stdout(&output);
for canonical in [
"minimal",
"standard",
"security",
"license-compliance",
"cra",
"bsi",
"comprehensive",
"cbom",
"ai-readiness",
] {
assert!(
help.contains(canonical),
"quality --help must list profile '{canonical}':\n{help}"
);
}
}
#[test]
fn invalid_standard_is_rejected_at_parse_with_possible_values() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "not-a-standard"])
.output()
.expect("validate command should run");
assert_eq!(output.status.code(), Some(2), "{}", stdout(&output));
let err = stderr(&output);
assert!(
err.contains("invalid value 'not-a-standard'"),
"stderr should explain the invalid value: {err}"
);
assert!(
err.contains("possible values"),
"stderr should list valid standards: {err}"
);
}
#[test]
fn invalid_profile_is_rejected_at_parse_with_possible_values() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--profile", "not-a-profile"])
.output()
.expect("quality command should run");
assert_eq!(output.status.code(), Some(2), "{}", stdout(&output));
let err = stderr(&output);
assert!(
err.contains("invalid value 'not-a-profile'"),
"stderr should explain the invalid value: {err}"
);
assert!(
err.contains("possible values"),
"stderr should list valid profiles: {err}"
);
}
#[test]
fn validate_standard_cra_phase1_is_reachable_and_cra_means_phase2() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "cra-phase1,cra", "--summary"])
.output()
.expect("validate command should run");
let json: serde_json::Value =
serde_json::from_str(stdout(&output).trim()).expect("summary should be JSON");
let standards: Vec<String> = json
.as_array()
.expect("multi-standard summary array")
.iter()
.map(|s| s["standard"].as_str().unwrap_or_default().to_string())
.collect();
assert!(
standards.iter().any(|s| s.contains("Phase 1")),
"cra-phase1 must select the Phase 1 profile: {standards:?}"
);
assert!(
standards.iter().any(|s| s.contains("Phase 2")),
"plain cra must keep meaning Phase 2: {standards:?}"
);
}
#[test]
fn validate_standard_aliases_parse_through_clap() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "NIST-SSDF,cra-2026,Tr03183", "--summary"])
.output()
.expect("validate command should run");
let json: serde_json::Value =
serde_json::from_str(stdout(&output).trim()).expect("summary should be JSON");
assert_eq!(
json.as_array().map(Vec::len),
Some(3),
"three aliased standards must produce three results: {json}"
);
}
#[test]
fn validate_standard_list_with_spaces_after_commas_parses() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "ntia, cra", "--summary"])
.output()
.expect("validate command should run");
let err = stderr(&output);
assert_ne!(
output.status.code(),
Some(2),
"'ntia, cra' must not be a usage error: {err}"
);
let json: serde_json::Value =
serde_json::from_str(stdout(&output).trim()).expect("summary should be JSON");
let standards: Vec<String> = json
.as_array()
.expect("multi-standard summary array")
.iter()
.map(|s| s["standard"].as_str().unwrap_or_default().to_string())
.collect();
assert_eq!(standards.len(), 2, "both standards must run: {standards:?}");
assert!(
standards.iter().any(|s| s.contains("NTIA")) && standards.iter().any(|s| s.contains("CRA")),
"expected NTIA + CRA results: {standards:?}"
);
}
#[test]
fn validate_summary_overrides_unsupported_output_format() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "ntia", "--summary", "-o", "html"])
.output()
.expect("validate command should run");
let err = stderr(&output);
assert!(
!err.contains("not supported by"),
"--summary must skip the format gate: {err}"
);
let json: serde_json::Value =
serde_json::from_str(stdout(&output).trim()).expect("summary should be JSON");
assert!(
json["standard"].is_string(),
"expected the compact summary JSON: {json}"
);
}
fn assert_rejects_typod_product_class(output: &Output, command: &str) {
assert!(
!output.status.success(),
"{command}: a typo'd --cra-product-class must be a hard error"
);
let err = stderr(output);
assert!(
err.contains("critcal"),
"{command}: error must name the bad value: {err}"
);
for valid in [
"default",
"important-class-1",
"important-class-2",
"critical",
] {
assert!(
err.contains(valid),
"{command}: error must list '{valid}': {err}"
);
}
}
#[test]
fn validate_rejects_typod_cra_product_class() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "cra", "--cra-product-class", "critcal"])
.arg("--summary")
.output()
.expect("validate command should run");
assert_rejects_typod_product_class(&output, "validate");
}
#[test]
fn quality_rejects_typod_cra_product_class() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--profile", "cra", "--cra-product-class", "critcal"])
.output()
.expect("quality command should run");
assert_rejects_typod_product_class(&output, "quality");
}
#[test]
fn view_rejects_typod_cra_product_class() {
let output = base_command()
.arg("view")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "json", "--cra-product-class", "critcal"])
.output()
.expect("view command should run");
assert_rejects_typod_product_class(&output, "view");
}
#[test]
fn cra_docs_rejects_typod_cra_product_class() {
let dir = tempfile::tempdir().expect("temp dir");
let output = base_command()
.arg("cra-docs")
.arg(fixture_path("demo-new.cdx.json"))
.arg("--output")
.arg(dir.path().join("dossier"))
.args(["--cra-product-class", "critcal"])
.output()
.expect("cra-docs command should run");
assert_rejects_typod_product_class(&output, "cra-docs");
}
#[test]
fn validate_accepts_valid_cra_product_class() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "cra", "--cra-product-class", "critical"])
.arg("--summary")
.output()
.expect("validate command should run");
let err = stderr(&output);
assert!(
!err.contains("Invalid product class"),
"valid class must parse: {err}"
);
serde_json::from_str::<serde_json::Value>(stdout(&output).trim())
.expect("summary should be JSON");
}
#[test]
fn quality_fail_on_noncompliant_exits_0_on_na_ai_readiness_run() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--profile", "ai-readiness", "--fail-on-noncompliant"])
.output()
.expect("quality command should run");
assert_eq!(
output.status.code(),
Some(0),
"N/A run must not trip --fail-on-noncompliant; stderr:\n{}",
stderr(&output)
);
assert!(
stdout(&output).contains("N/A"),
"the rendered report should say N/A: {}",
stdout(&output)
);
}
#[test]
fn quality_accepts_as_of_like_validate() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--profile", "cra", "--as-of", "2027-01-01T00:00:00"])
.output()
.expect("quality command should run");
assert_eq!(
output.status.code(),
Some(0),
"quality --as-of must parse and run; stderr:\n{}",
stderr(&output)
);
}
#[test]
fn quality_rejects_invalid_as_of_with_a_clear_error() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--profile", "cra", "--as-of", "not-a-date"])
.output()
.expect("quality command should run");
assert!(!output.status.success());
let err = stderr(&output);
assert!(
err.contains("invalid --as-of") && err.contains("RFC 3339"),
"error must explain the accepted forms: {err}"
);
}
#[test]
fn diff_rejects_oscal_json_output() {
let output = base_command()
.arg("diff")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "oscal-json"])
.output()
.expect("diff command should run");
assert!(
!output.status.success(),
"diff -o oscal-json must fail instead of emitting plain JSON"
);
let err = stderr(&output);
assert!(
err.contains("not supported by `sbom-tools diff`"),
"stderr should name the unsupported combination: {err}"
);
}
#[test]
fn view_rejects_oscal_json_output() {
let output = base_command()
.arg("view")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "oscal-json"])
.output()
.expect("view command should run");
assert!(
!output.status.success(),
"view -o oscal-json must fail instead of emitting plain JSON"
);
let err = stderr(&output);
assert!(
err.contains("not supported by `sbom-tools view`"),
"stderr should name the unsupported combination: {err}"
);
}
#[test]
fn validate_rejects_html_output_and_lists_supported_formats() {
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "ntia", "-o", "html"])
.output()
.expect("validate command should run");
assert!(
!output.status.success(),
"validate -o html must fail instead of silently emitting text"
);
let err = stderr(&output);
assert!(
err.contains("not supported by `sbom-tools validate`"),
"stderr should name the unsupported combination: {err}"
);
assert!(
err.contains("oscal-json"),
"stderr should list the formats validate does support: {err}"
);
}
#[test]
fn quality_rejects_oscal_json_output() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "oscal-json"])
.output()
.expect("quality command should run");
assert!(
!output.status.success(),
"quality -o oscal-json must fail instead of silently emitting text"
);
let err = stderr(&output);
assert!(
err.contains("not supported by `sbom-tools quality`"),
"stderr should name the unsupported combination: {err}"
);
}
#[test]
fn quality_explicit_missing_sidecar_is_hard_error() {
let output = base_command()
.arg("quality")
.arg(fixture_path("demo-new.cdx.json"))
.args([
"--profile",
"cra",
"--cra-sidecar",
"/nonexistent/side.cra.json",
])
.output()
.expect("quality command should run");
assert!(
!output.status.success(),
"an explicitly passed broken sidecar must abort quality"
);
assert!(
stderr(&output).contains("Failed to load CRA sidecar"),
"{}",
stderr(&output)
);
}
#[test]
fn view_explicit_missing_sidecar_is_hard_error() {
let output = base_command()
.arg("view")
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "json", "--cra-sidecar", "/nonexistent/side.cra.json"])
.output()
.expect("view command should run");
assert!(
!output.status.success(),
"an explicitly passed broken sidecar must abort view"
);
assert!(
stderr(&output).contains("Failed to load CRA sidecar"),
"{}",
stderr(&output)
);
}
#[test]
fn validate_sidecar_with_snake_case_typo_names_key_and_hints_camel_case() {
let dir = tempfile::tempdir().expect("temp dir");
let sidecar = dir.path().join("typo.cra.json");
std::fs::write(&sidecar, r#"{"security_contact": "security@example.com"}"#)
.expect("write sidecar");
let output = base_command()
.arg("validate")
.arg(fixture_path("demo-new.cdx.json"))
.args(["--standard", "cra", "--summary"])
.arg("--cra-sidecar")
.arg(&sidecar)
.output()
.expect("validate command should run");
assert!(
!output.status.success(),
"a typo'd sidecar key must fail loudly instead of deserializing to all-None"
);
let err = stderr(&output);
assert!(
err.contains("security_contact"),
"error must name the offending key: {err}"
);
assert!(
err.contains("camelCase"),
"error must hint at camelCase keys: {err}"
);
}
#[test]
fn side_by_side_honours_no_color_flag() {
let output = base_command()
.arg("diff")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "side-by-side"])
.output()
.expect("diff command should run");
let rendered = stdout(&output);
assert!(
rendered.contains("lodash"),
"expected side-by-side content, got:\n{rendered}\n{}",
stderr(&output)
);
assert!(
!rendered.contains('\u{1b}'),
"--no-color must suppress ANSI escapes in side-by-side output:\n{}",
stderr(&output)
);
}
#[test]
fn side_by_side_honours_no_color_env() {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sbom-tools"));
cmd.env("RUST_LOG", "error");
cmd.env("NO_COLOR", "1");
let output = cmd
.arg("diff")
.arg(fixture_path("demo-old.cdx.json"))
.arg(fixture_path("demo-new.cdx.json"))
.args(["-o", "side-by-side"])
.output()
.expect("diff command should run");
let rendered = stdout(&output);
assert!(
rendered.contains("lodash"),
"expected side-by-side content, got:\n{rendered}\n{}",
stderr(&output)
);
assert!(
!rendered.contains('\u{1b}'),
"NO_COLOR=1 must suppress ANSI escapes in side-by-side output:\n{}",
stderr(&output)
);
}
#[test]
fn sbomqs_json_is_rejected_by_diff_and_view() {
let old = fixture_path("demo-old.cdx.json");
let new = fixture_path("demo-new.cdx.json");
let diff = base_command()
.arg("diff")
.arg(&old)
.arg(&new)
.args(["-o", "sbomqs-json"])
.output()
.expect("diff should run");
assert!(
!diff.status.success(),
"diff must reject sbomqs-json instead of emitting diff JSON: {}",
stdout(&diff)
);
assert!(
stderr(&diff).contains("quality -o sbomqs-json"),
"the error should point at the command that renders it: {}",
stderr(&diff)
);
let view = base_command()
.arg("view")
.arg(&new)
.args(["-o", "sbomqs-json"])
.output()
.expect("view should run");
assert!(
!view.status.success(),
"view must reject sbomqs-json: {}",
stdout(&view)
);
}
#[test]
fn empty_no_color_env_is_treated_as_unset() {
unsafe { std::env::set_var("NO_COLOR", "") };
let empty = sbom_tools::pipeline::should_use_color(false);
unsafe { std::env::set_var("NO_COLOR", "1") };
let set = sbom_tools::pipeline::should_use_color(false);
unsafe { std::env::remove_var("NO_COLOR") };
let unset = sbom_tools::pipeline::should_use_color(false);
assert_eq!(
empty, unset,
"an empty NO_COLOR= must behave exactly like an unset one"
);
assert!(!set, "a non-empty NO_COLOR must disable colour");
}