use std::io::Write as _;
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt as _;
use predicates::str::contains;
fn stdout(args: &[&str]) -> String {
let out = cli()
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).expect("the CLI writes UTF-8")
}
fn valid_ubl() -> String {
use en16931::invoice::*;
use en16931::{Date, Identifier, Invoice, InvoiceAmount, InvoiceLine, Percentage, Quantity};
let party = |name: &str, country: &str| Party {
name: Some(name.to_owned()),
vat_identifier: Some(format!("{country}123456789")),
electronic_address: Some(Identifier::schemed(name, "0088")),
address: PostalAddress {
city: Some("Musterstadt".to_owned()),
post_code: Some("12345".to_owned()),
country: Some(Code::new(country)),
..Default::default()
},
..Default::default()
};
let invoice = Invoice::builder(
en16931::profiles::EN16931.specification_id,
"CLI-1",
Date::parse("2026-07-31").expect("date"),
"380",
"EUR",
)
.seller(party("Seller GmbH", "DE"))
.buyer(party("Buyer BV", "NL"))
.due_in_days(14)
.line(InvoiceLine::new(
"1",
"Widget",
Quantity::ONE,
"C62",
InvoiceAmount::parse("100.00").expect("amount"),
"S",
Some(Percentage::new(rust_decimal::Decimal::from(19))),
))
.build_reconciled()
.expect("reconciles");
assert!(en16931::validate(&invoice).is_valid());
en16931_formats::ubl::to_string(&invoice)
}
fn write_temp(name: &str, body: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let unique = NEXT.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"en16931-cli-{}-{unique}-{name}",
std::process::id()
));
let mut f = std::fs::File::create(&path).expect("create fixture");
f.write_all(body.as_bytes()).expect("write fixture");
f.sync_all().expect("flush fixture");
path
}
fn cli() -> Command {
Command::cargo_bin("en16931").expect("the binary is built")
}
#[test]
fn a_valid_invoice_exits_zero() {
let path = write_temp("valid.xml", &valid_ubl());
cli()
.args(["validate", path.to_str().unwrap()])
.assert()
.success()
.stdout(contains("valid"));
}
#[test]
fn an_invalid_invoice_exits_one_and_says_which_rules() {
let path = write_temp(
"empty.xml",
r#"<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"/>"#,
);
cli()
.args(["validate", path.to_str().unwrap()])
.assert()
.code(1)
.stdout(contains("BR-02")) .stdout(contains("BR-16")); }
#[test]
fn an_unreadable_document_exits_two_not_one() {
cli()
.args(["validate", "/definitely/not/here.xml"])
.assert()
.code(2);
let path = write_temp("nonsense.xml", "<html/>");
cli()
.args(["validate", path.to_str().unwrap()])
.assert()
.code(2)
.stderr(contains("not an e-invoice"));
}
#[test]
fn the_rule_set_comes_from_bt_24_by_default() {
let path = write_temp("valid.xml", &valid_ubl());
cli()
.args(["validate", path.to_str().unwrap()])
.assert()
.success()
.stdout(contains("EN 16931 validation"));
cli()
.args([
"validate",
path.to_str().unwrap(),
"--profile",
"XRechnung 3.0",
])
.assert()
.code(1)
.stdout(contains("BR-DE-15"));
}
#[test]
fn an_unknown_profile_is_refused_with_the_list() {
cli()
.args(["validate", "-", "--profile", "XRechnung 9.9"])
.assert()
.code(2)
.stderr(contains("unknown profile"))
.stderr(contains("xrechnung-extension"))
.stderr(contains("peppol"));
}
#[test]
fn a_profile_is_selectable_by_slug_name_or_bt24() {
let path = write_temp("slug.xml", &valid_ubl());
let file = path.to_str().expect("utf-8 path");
for spelling in [
"xrechnung",
"XRECHNUNG",
"XRechnung 3.0",
en16931::profiles::XRECHNUNG.specification_id,
] {
cli()
.args(["validate", file, "--profile", spelling, "--format", "json"])
.assert()
.stdout(contains("XRechnung 3.0"));
}
}
#[test]
fn a_conversion_round_trips_through_the_model() {
let path = write_temp("valid.xml", &valid_ubl());
let cii = cli()
.args(["convert", path.to_str().unwrap(), "--to", "cii"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cii = String::from_utf8(cii).expect("UTF-8");
assert!(cii.contains("CrossIndustryInvoice"));
let back = write_temp("roundtrip.xml", &cii);
cli()
.args(["validate", back.to_str().unwrap()])
.assert()
.success();
}
#[test]
fn converting_for_a_profile_refuses_rather_than_writing_something_invalid() {
let path = write_temp("valid.xml", &valid_ubl());
cli()
.args([
"convert",
path.to_str().unwrap(),
"--to",
"cii",
"--profile",
"XRechnung 3.0",
])
.assert()
.code(1)
.stdout(predicates::str::is_empty()) .stderr(contains("BR-DE-15"));
}
#[test]
fn stdin_is_a_document_source() {
cli()
.args(["inspect", "-"])
.write_stdin(valid_ubl())
.assert()
.success()
.stdout(contains("UBL 2.1"))
.stdout(contains("CLI-1"));
}
#[test]
fn json_output_carries_the_versioned_schema() {
let path = write_temp("valid.xml", &valid_ubl());
let out = cli()
.args(["validate", path.to_str().unwrap(), "--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let value: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON");
assert_eq!(value["schema"], en16931::report::SCHEMA);
assert_eq!(value["valid"], true);
assert_eq!(value["documents"].as_array().map(Vec::len), Some(1));
}
#[test]
fn svrl_output_is_well_formed() {
let path = write_temp(
"empty.xml",
r#"<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"/>"#,
);
let out = cli()
.args(["validate", path.to_str().unwrap(), "--format", "svrl"])
.assert()
.code(1)
.get_output()
.stdout
.clone();
let xml = String::from_utf8(out).expect("UTF-8");
roxmltree::Document::parse(&xml).expect("well-formed SVRL");
}
#[test]
fn inspect_refuses_svrl_and_says_why() {
cli()
.args(["inspect", "-", "--format", "svrl"])
.write_stdin(valid_ubl())
.assert()
.code(2)
.stderr(contains("does not validate"));
}
#[test]
fn suppressions_are_recorded_in_the_output() {
let path = write_temp("valid.xml", &valid_ubl());
cli()
.args([
"validate",
path.to_str().unwrap(),
"--profile",
"XRechnung 3.0",
"--without",
"BR-DE-15",
])
.assert()
.code(1) .stdout(contains("suppressed and NOT checked: BR-DE-15"));
}
#[test]
fn explain_resolves_rules_restrictions_and_every_spelling() {
for query in ["BR-CO-14", "br-co-3", "BR-IG-1"] {
cli().args(["explain", query]).assert().success();
}
cli()
.args(["explain", "BR-DE-3"])
.assert()
.success()
.stdout(contains("restriction"));
cli()
.args(["explain", "BR-NOPE-99"])
.assert()
.code(2)
.stderr(contains("no rule or restriction"));
}
#[test]
fn profiles_lists_what_this_build_can_check() {
cli()
.arg("profiles")
.assert()
.success()
.stdout(contains("XRechnung 3.0"))
.stdout(contains(en16931::ARTEFACT_VERSION))
.stdout(contains(en16931::ATTRIBUTION));
}
#[test]
fn a_batch_run_names_every_document_and_fails_on_the_worst() {
let good = write_temp("valid.xml", &valid_ubl());
let bad = write_temp(
"empty.xml",
r#"<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"/>"#,
);
cli()
.args(["validate", good.to_str().unwrap(), bad.to_str().unwrap()])
.assert()
.code(1)
.stdout(contains("2 document(s), 1 invalid"));
}
#[test]
fn strict_turns_advisories_into_failures() {
let mut inv = en16931_formats::ubl::from_str(&valid_ubl())
.expect("readable")
.invoice;
inv.payment = Some(en16931::invoice::PaymentInstructions {
means_code: Some(en16931::invoice::Code::new("48")),
means: Some(en16931::invoice::PaymentMeans::Card(
en16931::invoice::PaymentCard {
primary_account_number: Some("4111111111111111".to_owned()),
holder_name: Some("A Muster".to_owned()),
},
)),
..Default::default()
});
let path = write_temp("full-pan.xml", &en16931_formats::ubl::to_string(&inv));
cli()
.args(["validate", path.to_str().unwrap()])
.assert()
.success()
.stdout(contains("BR-51"));
cli()
.args(["validate", path.to_str().unwrap(), "--strict"])
.assert()
.code(1)
.stdout(contains("BR-51"));
}
#[test]
fn the_catalogue_matches_what_each_profile_actually_runs() {
let total = |args: &[&str]| -> usize {
let text = stdout(args);
text.lines()
.find_map(|l| l.split_once(" check(s):").map(|(n, _)| n.to_owned()))
.and_then(|n| n.trim().parse().ok())
.unwrap_or_else(|| panic!("no count in:\n{text}"))
};
for p in en16931::profiles::ALL {
assert_eq!(
total(&["rules", "--profile", p.id]),
p.check_ids().count(),
"{} — the catalogue and the profile disagree",
p.id
);
}
let text = stdout(&["rules", "--profile", "XRechnung 3.0"]);
for id in ["BR-DE-1", "BR-DE-15", "BR-DE-17", "BR-DE-21"] {
assert!(text.contains(id), "{id} missing from the text catalogue");
}
assert!(
!text.contains("BT-0"),
"a whole-group restriction must not invent a business-term id:\n{text}"
);
let json = stdout(&["rules", "--profile", "XRechnung 3.0", "--format", "json"]);
assert!(
json.contains("\"BR-DE-15\"") && json.contains("\"restriction\": \"mandatory\""),
"the JSON catalogue is what people diff across releases"
);
}
#[test]
fn a_rule_with_two_instances_reports_both() {
let text = stdout(&["explain", "PEPPOL-EN16931-R120"]);
assert!(text.contains("varies by profile"), "{text}");
assert!(text.contains("Peppol BIS Billing 3.0 (fatal)"), "{text}");
assert!(text.contains("XRechnung 3.0 (warning)"), "{text}");
let peppol = stdout(&["rules", "--profile", "peppol"]);
let xr = stdout(&["rules", "--profile", "xrechnung"]);
let row = |s: &str| {
s.lines()
.find(|l| l.starts_with("PEPPOL-EN16931-R120"))
.unwrap_or_default()
.to_owned()
};
assert!(row(&peppol).contains("fatal"), "{}", row(&peppol));
assert!(row(&xr).contains("warning"), "{}", row(&xr));
}
#[test]
fn a_rule_with_one_severity_says_so_plainly() {
let text = stdout(&["explain", "BR-CO-14"]);
assert!(text.contains("[fatal]"), "{text}");
assert!(!text.contains("varies"), "{text}");
assert!(text.contains("run by: EN 16931, XRechnung 3.0,"), "{text}");
}
#[test]
fn a_restriction_reports_the_severity_each_profile_runs_it_at() {
let text = stdout(&["explain", "BR-DE-17"]);
assert!(text.contains("[restriction]"), "{text}");
assert!(text.contains("XRechnung 3.0 (warning)"), "{text}");
let text = stdout(&["explain", "BR-DE-3"]);
assert!(text.contains("XRechnung 3.0 (fatal)"), "{text}");
}
#[test]
fn the_catalogue_reports_the_profiles_own_severity() {
let row = |args: &[&str]| -> String {
stdout(args)
.lines()
.find(|l| l.starts_with("BR-CL-23 "))
.unwrap_or_else(|| panic!("BR-CL-23 not listed"))
.to_owned()
};
assert!(row(&["rules", "--profile", "en16931"]).contains("fatal"));
assert!(row(&["rules", "--profile", "xrechnung"]).contains("warning"));
}
#[test]
fn the_catalogue_filters_by_business_term_in_either_spelling() {
for term in ["BT-117", "117"] {
cli()
.args(["rules", "--term", term])
.assert()
.success()
.stdout(contains("BR-CO-17"))
.stdout(predicates::str::contains("BR-01").not());
}
cli()
.args(["rules", "--term", "not-a-term"])
.assert()
.code(2)
.stderr(contains("not a business term"));
}
#[test]
fn the_catalogue_json_is_parseable_and_carries_its_provenance() {
let out = cli()
.args(["rules", "--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON");
assert_eq!(v["artefacts"], en16931::ARTEFACT_VERSION);
assert_eq!(v["attribution"], en16931::ATTRIBUTION);
assert!(v["rules"].as_array().is_some_and(|r| r.len() > 300));
}
#[test]
fn completions_and_a_man_page_can_be_generated() {
for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
cli()
.args(["generate", shell])
.assert()
.success()
.stdout(contains("en16931"));
}
cli()
.args(["generate", "man"])
.assert()
.success()
.stdout(contains(".TH en16931 1"));
}
#[test]
fn a_closed_pipe_is_silent() {
let e = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
assert_eq!(e.kind(), std::io::ErrorKind::BrokenPipe);
let src = include_str!("../src/output.rs");
assert!(
!src.contains("println!") && !src.contains("print!("),
"output.rs must build strings, not print — see `write_out`"
);
}
#[test]
fn hostile_documents_are_refused_rather_than_fatal() {
let ns = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2";
let deep = write_temp(
"hostile-deep.xml",
&format!(
"<Invoice xmlns=\"{ns}\">{}{}</Invoice>",
"<a>".repeat(5_000),
"</a>".repeat(5_000)
),
);
cli()
.args(["validate", deep.to_str().unwrap()])
.assert()
.code(2)
.stderr(contains("nested"));
let laughs = write_temp(
"hostile-laughs.xml",
&format!(
"<?xml version=\"1.0\"?>\n<!DOCTYPE Invoice [\n<!ENTITY e0 \"aaaaaaaaaa\">\n{}\n]>\n\
<Invoice xmlns=\"{ns}\"><ID>&e9;</ID></Invoice>",
(1..=9)
.map(|i| format!("<!ENTITY e{i} \"&e{};&e{};\">", i - 1, i - 1))
.collect::<Vec<_>>()
.join("\n")
),
);
cli()
.args(["validate", laughs.to_str().unwrap()])
.assert()
.code(2)
.stderr(contains("DTD"));
let xxe = write_temp(
"hostile-xxe.xml",
&format!(
"<?xml version=\"1.0\"?>\n\
<!DOCTYPE Invoice [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>\n\
<Invoice xmlns=\"{ns}\"><ID>&xxe;</ID></Invoice>"
),
);
cli()
.args(["validate", xxe.to_str().unwrap()])
.assert()
.code(2)
.stderr(contains("DTD"));
}
#[test]
fn the_same_invoice_in_two_syntaxes_is_identical() {
let ubl = write_temp("diff-ubl.xml", &valid_ubl());
let cii = std::env::temp_dir().join(format!("en16931-cli-{}-diff.cii.xml", std::process::id()));
cli()
.args(["convert", ubl.to_str().unwrap(), "--to", "cii", "-o"])
.arg(&cii)
.assert()
.success();
cli()
.args(["diff", ubl.to_str().unwrap(), cii.to_str().unwrap()])
.assert()
.success()
.stdout(contains("identical as invoices"));
let _ = std::fs::remove_file(&cii);
}
#[test]
fn a_trailing_zero_is_not_a_difference() {
let a = write_temp("diff-scale-a.xml", &valid_ubl());
let b = write_temp(
"diff-scale-b.xml",
&valid_ubl()
.replace(">19<", ">19.00<")
.replace(">19.00.00<", ">19.00<"),
);
cli()
.args(["diff", a.to_str().unwrap(), b.to_str().unwrap()])
.assert()
.success()
.stdout(contains("identical as invoices"));
}
#[test]
fn a_changed_field_is_reported_and_exits_one() {
let a = write_temp("diff-a.xml", &valid_ubl());
let b = write_temp(
"diff-b.xml",
&valid_ubl().replace("Musterstadt", "Nirgendwo"),
);
cli()
.args(["diff", a.to_str().unwrap(), b.to_str().unwrap()])
.assert()
.code(1)
.stdout(contains("seller.address.city"))
.stdout(contains("\"Musterstadt\" → \"Nirgendwo\""));
}
#[test]
fn an_unreadable_side_exits_two_not_one() {
let a = write_temp("diff-ok.xml", &valid_ubl());
cli()
.args(["diff", a.to_str().unwrap(), "/nonexistent/invoice.xml"])
.assert()
.code(2);
}
#[test]
fn the_json_diff_is_machine_readable() {
let a = write_temp("diff-json-a.xml", &valid_ubl());
let b = write_temp(
"diff-json-b.xml",
&valid_ubl().replace("Musterstadt", "Nirgendwo"),
);
let out = cli()
.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format",
"json",
])
.assert()
.code(1)
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON");
assert_eq!(v["identical"], serde_json::json!(false));
let d = &v["differences"][0];
assert_eq!(d["change"], serde_json::json!("~"));
assert!(d["left"].is_string(), "left is a value, not a JSON blob");
}
#[test]
fn svrl_is_refused_for_a_comparison() {
let a = write_temp("diff-svrl-a.xml", &valid_ubl());
let b = write_temp("diff-svrl-b.xml", &valid_ubl());
cli()
.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format",
"svrl",
])
.assert()
.code(2)
.stderr(contains("not a comparison"));
}
#[test]
fn categories_exit_codes_are_the_same_three_as_validate() {
cli().args(["categories", "S", "Z"]).assert().success();
cli()
.args(["categories", "S", "O"])
.assert()
.code(1)
.stdout(contains("BR-O-11"));
cli().args(["categories", "S", "X"]).assert().code(2);
}
#[test]
fn categories_are_named_not_only_coded() {
cli()
.args(["categories", "O"])
.assert()
.success()
.stdout(contains("services outside the scope of VAT"));
}
#[test]
fn categories_are_case_sensitive() {
cli()
.args(["categories", "s"])
.assert()
.code(2)
.stderr(contains("case-sensitive"));
}
#[test]
fn the_municipal_case_is_refused_with_its_reason_and_its_way_out() {
cli()
.args(["categories", "S", "O"])
.assert()
.code(1)
.stdout(contains("REFUSED"))
.stdout(contains("own document"));
}