use std::path::PathBuf;
use std::process::Command;
fn sbom_diff() -> Command {
Command::new(env!("CARGO_BIN_EXE_sbom-diff"))
}
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name)
}
#[test]
fn fail_on_added_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on added-components"),
"stderr should mention the violated condition"
);
}
#[test]
fn fail_on_removed_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("removed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on removed-components"));
}
#[test]
fn fail_on_changed_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("changed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on changed-components"));
}
#[test]
fn fail_on_deps_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("deps")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on deps"));
}
#[test]
fn fail_on_missing_hashes_exits_3() {
let out = sbom_diff()
.arg(fixture("missing-hashes-old.json"))
.arg(fixture("missing-hashes-new.json"))
.arg("--fail-on")
.arg("missing-hashes")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on missing-hashes"));
}
#[test]
fn fail_on_no_violation_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--fail-on")
.arg("removed-components")
.arg("--fail-on")
.arg("changed-components")
.arg("--fail-on")
.arg("deps")
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_multiple_conditions_all_checked() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--fail-on")
.arg("removed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on added-components"),
"should report added-components violation"
);
assert!(
stderr.contains("--fail-on removed-components"),
"should report removed-components violation"
);
}
#[test]
fn fail_on_license_changed_exits_3() {
let out = sbom_diff()
.arg(fixture("license-changed-old.json"))
.arg(fixture("license-changed-new.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on license-changed"),
"stderr should mention the violated condition, got: {}",
stderr
);
assert!(
stderr.contains("license changed on component"),
"stderr should report the changed component, got: {}",
stderr
);
assert!(
stderr.contains("introduces license(s)"),
"stderr should report the added component's licenses, got: {}",
stderr
);
}
#[test]
fn fail_on_license_changed_no_change_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_license_changed_no_violation_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn deny_license_match_exits_2() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("MIT")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("license MIT is denied"));
}
#[test]
fn deny_license_case_insensitive() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("mit")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn deny_license_no_match_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("GPL-3.0-only")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn allow_license_violation_exits_2() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--allow-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("not allowed"));
}
#[test]
fn allow_license_all_match_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--allow-license")
.arg("MIT")
.arg("--allow-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn license_violation_takes_precedence_over_fail_on() {
let license_only = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("MIT")
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(license_only.status.code(), Some(2));
let fail_on_only = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(fail_on_only.status.code(), Some(3));
}
#[test]
fn quiet_suppresses_stdout() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--quiet")
.output()
.unwrap();
assert!(
out.stdout.is_empty(),
"stdout should be empty with --quiet, got: {}",
String::from_utf8_lossy(&out.stdout)
);
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn quiet_still_emits_stderr_on_violation() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--quiet")
.arg("--deny-license")
.arg("MIT")
.output()
.unwrap();
assert!(out.stdout.is_empty(), "stdout should be empty with --quiet");
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("license MIT is denied"),
"stderr should still contain error messages"
);
}
#[test]
fn quiet_preserves_fail_on_exit_code() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--quiet")
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert!(out.stdout.is_empty());
assert_eq!(out.status.code(), Some(3));
}
#[test]
fn auto_detects_cyclonedx_json() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_spdx_json() {
let out = sbom_diff()
.arg(fixture("golden-old.spdx.json"))
.arg(fixture("golden-new.spdx.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_cyclonedx_xml() {
let out = sbom_diff()
.arg(fixture("golden-old.cdx.xml"))
.arg(fixture("golden-new.cdx.xml"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_spdx_tag_value() {
let out = sbom_diff()
.arg(fixture("golden-old.spdx"))
.arg(fixture("golden-new.spdx"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn explicit_format_overrides_auto() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--format")
.arg("cyclonedx")
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
}
#[test]
fn wrong_explicit_format_fails() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--format")
.arg("spdx")
.output()
.unwrap();
assert_ne!(out.status.code(), Some(0));
}
#[test]
fn summary_text_is_compact() {
let full = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.output()
.unwrap();
let summary = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.output()
.unwrap();
assert_eq!(summary.status.code(), Some(0));
let full_out = String::from_utf8_lossy(&full.stdout);
let summary_out = String::from_utf8_lossy(&summary.stdout);
assert!(
summary_out.len() < full_out.len(),
"summary ({} bytes) should be shorter than full output ({} bytes)",
summary_out.len(),
full_out.len()
);
assert!(summary_out.contains("Added"));
assert!(summary_out.contains("Removed"));
}
#[test]
fn summary_json_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("summary JSON should parse");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
assert_eq!(v["changed"], 2);
assert_eq!(v["unchanged"], 2);
assert_eq!(v["edge_changes"], 1);
}
#[test]
fn summary_markdown_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("markdown")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty());
}
#[test]
fn summary_with_quiet_produces_no_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--quiet")
.output()
.unwrap();
assert!(out.stdout.is_empty());
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn identity_diff_exits_0_with_no_output_changes() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
assert_eq!(v["changed"], 0);
}