mod ubl;
mod common;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use en16931::validate;
struct Case {
file: String,
rule: String,
must_fire: bool,
description: String,
xml: String,
}
const MIN_CEN_ASSERTIONS: usize = 1_000;
const MIN_KOSIT_MUTATIONS: usize = 350;
const MIN_EXAMPLE_INVOICES: usize = 55;
fn suite_dirs() -> Vec<PathBuf> {
let Some(root) = common::require("CEN unit tests") else {
return Vec::new();
};
let root = root.join("eInvoicing-EN16931/test");
["Invoice-unit-UBL", "CreditNote-unit-UBL"]
.iter()
.map(|d| root.join(d))
.filter(|p| p.exists())
.collect()
}
fn cases() -> Vec<Case> {
let mut out = Vec::new();
for dir in suite_dirs() {
let mut files: Vec<_> = std::fs::read_dir(&dir)
.expect("read suite dir")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
.collect();
files.sort();
for path in files {
let text = std::fs::read_to_string(&path).expect("read case");
let doc = match roxmltree::Document::parse(&text) {
Ok(d) => d,
Err(e) => panic!("{}: {e}", path.display()),
};
let file = path.file_name().unwrap().to_string_lossy().into_owned();
for test in doc.descendants().filter(|n| n.tag_name().name() == "test") {
let assertion = test.children().find(|n| n.tag_name().name() == "assert");
let Some(assertion) = assertion else { continue };
let mut rule = None;
let mut must_fire = false;
for c in assertion.children().filter(roxmltree::Node::is_element) {
match c.tag_name().name() {
"error" | "fatal" => {
rule = c.text().map(|t| t.trim().to_owned());
must_fire = true;
}
"success" => {
rule = c.text().map(|t| t.trim().to_owned());
must_fire = false;
}
_ => {}
}
}
let Some(rule) = rule else { continue };
let Some(root) = test
.children()
.find(|n| matches!(n.tag_name().name(), "Invoice" | "CreditNote"))
else {
continue;
};
out.push(Case {
file: file.clone(),
rule,
must_fire,
description: assertion
.children()
.find(|n| n.tag_name().name() == "description")
.and_then(|n| n.text())
.unwrap_or("")
.trim()
.to_owned(),
xml: text[root.range()].to_owned(),
});
}
}
}
out
}
fn unevaluated() -> BTreeSet<String> {
const UNEVALUATED: &[&str] = &[
"BR-12", "BR-13", "BR-14", "BR-15", "BR-22", "BR-24", "BR-26", "BR-31", "BR-32", "BR-36",
"BR-37", "BR-41", "BR-43", "BR-45", "BR-46", "BR-CL-03", "BR-CO-05", "BR-CO-06",
"BR-CO-07", "BR-CO-08",
];
en16931::validation::rules::CORE
.iter()
.filter(|r| {
let id = r.id.as_str();
id.starts_with("BR-DEC-") || UNEVALUATED.contains(&id)
})
.map(|r| r.id.as_str().to_owned())
.collect()
}
enum Outcome {
Agreed,
Diverged(&'static str),
Disagreed(String),
SkippedUnevaluated,
SkippedSyntaxRule,
SkippedMalformed(String),
SkippedUnreadable(String),
}
const DIVERGENCES: &[(&str, &str, &str)] = &[
(
"BR-08.xml",
"BR-08",
"an empty <cac:PostalAddress/> is an absent address here",
),
(
"BR-10.xml",
"BR-10",
"an empty <cac:PostalAddress/> is an absent address here",
),
(
"BR-19.xml",
"BR-19",
"an empty <cac:PostalAddress/> is an absent address here",
),
(
"BR-55.xml",
"BR-55",
"a <cac:BillingReference/> with no child is an absent BG-3 here",
),
(
"BR-CO-19.xml",
"BR-CO-19",
"a <cac:InvoicePeriod/> with no dates is an absent BG-14 here",
),
(
"BR-CO-15.xml",
"BR-CO-15",
"two cac:TaxTotal elements cannot both be BT-110",
),
(
"BR-CO-15-2.xml",
"BR-CO-15",
"two cac:TaxTotal elements cannot both be BT-110",
),
];
fn run(case: &Case, reader: &mut ubl::Reader, unevaluated: &BTreeSet<String>) -> Outcome {
let canonical = en16931::validation::rules::CORE
.iter()
.find(|r| r.id.matches(&case.rule))
.map(|r| r.id.as_str().to_owned());
let Some(canonical) = canonical else {
if case.rule.starts_with("UBL-") || case.rule.starts_with("CII-") {
return Outcome::SkippedSyntaxRule;
}
return Outcome::SkippedUnreadable(format!("{} is not a registered rule", case.rule));
};
if unevaluated.contains(&canonical) {
return Outcome::SkippedUnevaluated;
}
let doc = match roxmltree::Document::parse(&case.xml) {
Ok(d) => d,
Err(e) => return Outcome::SkippedUnreadable(format!("parse: {e}")),
};
let before = reader.unmapped.len();
let malformed_before = reader.malformed.len();
let invoice = reader.read(doc.root_element());
if reader.malformed.len() > malformed_before {
let what = reader.malformed[malformed_before..].join(", ");
return Outcome::SkippedMalformed(what);
}
let report = validate(&invoice);
let fired = report.has(&canonical);
if fired == case.must_fire {
return Outcome::Agreed;
}
if let Some((_, _, why)) = DIVERGENCES
.iter()
.find(|(f, r, _)| *f == case.file && r.eq_ignore_ascii_case(&case.rule))
{
return Outcome::Diverged(why);
}
let grew = reader.unmapped.len() > before;
let verdict = if case.must_fire {
"the suite says it must fire; it did not"
} else {
"the suite says it must not fire; it did"
};
let hint = if grew {
" (the reader skipped an element in this fragment)"
} else {
""
};
Outcome::Disagreed(format!(
"{} [{}] {verdict}{hint}\n {}",
case.file, case.rule, case.description
))
}
#[test]
fn cen_conformance_suite() {
let cases = cases();
if cases.is_empty() {
eprintln!("skipping: artefacts not present (run `cargo xtask fetch`)");
return;
}
let unevaluated = unevaluated();
let mut reader = ubl::Reader::default();
let mut agreed = 0usize;
let mut skipped_unevaluated = 0usize;
let mut skipped_syntax = 0usize;
let mut diverged: BTreeMap<&str, usize> = BTreeMap::new();
let mut unreadable: Vec<String> = Vec::new();
let mut malformed: Vec<String> = Vec::new();
let mut disagreed: Vec<String> = Vec::new();
let mut per_rule: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for case in &cases {
match run(case, &mut reader, &unevaluated) {
Outcome::Agreed => {
agreed += 1;
per_rule.entry(case.rule.clone()).or_default().0 += 1;
}
Outcome::SkippedUnevaluated => skipped_unevaluated += 1,
Outcome::SkippedSyntaxRule => skipped_syntax += 1,
Outcome::Diverged(why) => *diverged.entry(why).or_default() += 1,
Outcome::SkippedMalformed(what) => malformed.push(format!("{} — {what}", case.file)),
Outcome::SkippedUnreadable(why) => unreadable.push(format!("{} — {why}", case.file)),
Outcome::Disagreed(why) => {
disagreed.push(why);
per_rule.entry(case.rule.clone()).or_default().1 += 1;
}
}
}
let run_count = agreed + disagreed.len() + diverged.values().sum::<usize>();
eprintln!(
"CEN conformance suite\n \
assertions: {}\n \
run: {run_count}\n \
agreed: {agreed} ({:.1}% of run)\n \
disagreed: {}\n \
diverged, declared: {}\n \
skipped, unevaluated: {skipped_unevaluated} (type-retired or undecidable)\n \
skipped, syntax rules: {skipped_syntax} (UBL-*/CII-*, `en16931-formats`' job)\n \
skipped, malformed: {} (value the model refuses at the boundary)\n \
skipped, unreadable: {}",
cases.len(),
100.0 * agreed as f64 / run_count.max(1) as f64,
disagreed.len(),
diverged.values().sum::<usize>(),
malformed.len(),
unreadable.len(),
);
for (why, n) in &diverged {
eprintln!(" {n} × {why}");
}
if !reader.unmapped.is_empty() {
eprintln!(" reader did not map:");
for u in &reader.unmapped {
eprintln!(" {u}");
}
}
assert!(
run_count >= MIN_CEN_ASSERTIONS,
"only {run_count} CEN assertions ran, against a floor of {MIN_CEN_ASSERTIONS}. \
The corpus shrank — a partial fetch, or a directory that moved upstream. \
100 % agreement over a smaller denominator is not the same claim."
);
assert!(
disagreed.is_empty(),
"{} of {run_count} CEN conformance assertions disagree with this crate:\n {}",
disagreed.len(),
disagreed.join("\n ")
);
assert!(
unreadable.is_empty(),
"{} case(s) could not be run:\n {}",
unreadable.len(),
unreadable.join("\n ")
);
let unused: Vec<_> = DIVERGENCES
.iter()
.filter(|(_, _, why)| !diverged.contains_key(why))
.map(|(f, r, _)| format!("{f} [{r}]"))
.collect();
assert!(
unused.is_empty(),
"{} declared divergence(s) no longer diverge — the rule got better and \
the table went stale; delete them:\n {}",
unused.len(),
unused.join("\n ")
);
}
#[test]
fn the_reader_maps_everything_the_suite_uses() {
let cases = cases();
if cases.is_empty() {
eprintln!("skipping: artefacts not present");
return;
}
let mut reader = ubl::Reader::default();
for case in &cases {
if let Ok(doc) = roxmltree::Document::parse(&case.xml) {
let _ = reader.read(doc.root_element());
}
}
const NO_BUSINESS_TERM: &[&str] = &[];
let unexpected: Vec<_> = reader
.unmapped
.iter()
.filter(|u| !NO_BUSINESS_TERM.contains(&u.as_str()))
.cloned()
.collect();
assert!(
unexpected.is_empty(),
"the UBL reader ignores {} element(s) the suite uses — every one is a \
rule it cannot be checking:\n {}",
unexpected.len(),
unexpected.join("\n ")
);
}
struct Mutation {
file: String,
profile: &'static en16931::Profile,
rule: String,
must_fire: bool,
description: String,
xml: String,
}
fn pi_attrs(body: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
let Some(eq) = body[i..].find('=') else { break };
let key = body[i..i + eq].trim().to_owned();
let rest = &body[i + eq + 1..];
let Some(open) = rest.find('"') else { break };
let Some(close) = rest[open + 1..].find('"') else {
break;
};
out.insert(key, rest[open + 1..open + 1 + close].to_owned());
i = i + eq + 1 + open + 1 + close + 1;
}
out
}
fn set_attribute(elem: &str, attr: &str, value: &str) -> String {
let needle = format!("{attr}=\"");
match elem.find(&needle) {
Some(i) => {
let rest = &elem[i + needle.len()..];
let end = rest.find('"').map_or(rest.len(), |e| e);
format!("{}{needle}{value}\"{}", &elem[..i], &rest[end + 1..])
}
None => elem.to_owned(),
}
}
fn drop_attribute(elem: &str, attr: &str) -> String {
let needle = format!("{attr}=\"");
match elem.find(&needle) {
Some(i) => {
let rest = &elem[i + needle.len()..];
let end = rest.find('"').map_or(rest.len(), |e| e);
format!("{}{}", &elem[..i], &rest[end + 1..])
}
None => elem.to_owned(),
}
}
fn mutations() -> Vec<Mutation> {
let root = match common::require("KoSIT mutation suite") {
Some(root) => root.join("xrechnung-schematron/test/instances"),
None => return Vec::new(),
};
let mut out = Vec::new();
for sub in ["ubl-inv", "ubl-cn"] {
let dir = root.join(sub);
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
let mut files: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
.collect();
files.sort();
for path in files {
let text = std::fs::read_to_string(&path).expect("read instance");
let Ok(doc) = roxmltree::Document::parse(&text) else {
continue;
};
let file = path.file_name().unwrap().to_string_lossy().into_owned();
let spec = doc
.root_element()
.children()
.find(|n| n.tag_name().name() == "CustomizationID")
.and_then(|n| n.text())
.unwrap_or("")
.trim();
let profile = en16931::profiles::for_specification_id(spec)
.unwrap_or(&en16931::profiles::XRECHNUNG);
for pi in doc.descendants().filter(|n| n.is_pi()) {
let Some(pi_data) = pi.pi() else { continue };
if pi_data.target != "xmute" {
continue;
}
let attrs = pi_attrs(pi_data.value.unwrap_or(""));
let Some(mutator) = attrs.get("mutator") else {
continue;
};
let (rules, must_fire) = match (
attrs.get("schematron-invalid"),
attrs.get("schematron-valid"),
) {
(Some(r), _) => (r.clone(), true),
(_, Some(r)) => (r.clone(), false),
_ => continue,
};
let target = pi
.next_siblings()
.find(roxmltree::Node::is_element)
.map(|n| n.range());
let attr = attrs.get("attribute");
let mutated = match (mutator.as_str(), target) {
("identity", _) => Some(text.clone()),
("remove", Some(r)) => {
let mut m = text.clone();
match attr {
Some(a) => {
let elem = &text[r.clone()];
m.replace_range(r, &drop_attribute(elem, a));
}
None => m.replace_range(r, ""),
}
Some(m)
}
("empty", Some(r)) => {
let elem = &text[r.clone()];
let replacement = match attr {
Some(a) => set_attribute(elem, a, ""),
None => match elem.find('>') {
Some(i) if !elem[..i].ends_with('/') => {
format!("{}/>", elem[..i].trim_end())
}
_ => elem.to_owned(),
},
};
let mut m = text.clone();
m.replace_range(r, &replacement);
Some(m)
}
_ => None,
};
let Some(xml) = mutated else { continue };
for rule in rules.split_whitespace() {
out.push(Mutation {
file: file.clone(),
profile,
rule: rule.trim_start_matches("xrubl:").to_owned(),
must_fire,
description: attrs.get("description").cloned().unwrap_or_default(),
xml: xml.clone(),
});
}
}
}
}
out
}
#[test]
fn xrechnung_mutation_suite() {
let all = mutations();
if all.is_empty() {
eprintln!("skipping: artefacts not present (run `cargo xtask fetch`)");
return;
}
let mut reader = ubl::Reader::default();
let mut agreed = 0usize;
let mut skipped = 0usize;
let mut disagreed: Vec<String> = Vec::new();
let retired = unevaluated();
const PROFILE_RETIRED: &[&str] = &[
"BR-DE-23-b",
"BR-DE-24-b",
"BR-DE-25-b",
"BR-DEX-13",
"BR-DEX-14",
"PEPPOL-EN16931-F001",
"PEPPOL-EN16931-R008",
"PEPPOL-EN16931-R043",
"PEPPOL-EN16931-R044",
"PEPPOL-EN16931-R051",
"PEPPOL-EN16931-R053",
"PEPPOL-EN16931-R054",
"PEPPOL-EN16931-R080",
"PEPPOL-EN16931-R100",
"PEPPOL-EN16931-R101",
"PEPPOL-EN16931-CL007",
];
for m in &all {
if retired.contains(&m.rule) || PROFILE_RETIRED.contains(&m.rule.as_str()) {
skipped += 1;
continue;
}
let known = en16931::validation::rules::CORE
.iter()
.chain(m.profile.extra_rules.iter())
.any(|r| r.id.matches(&m.rule))
|| m.profile.restrictions.iter().any(|r| {
en16931::validation::RuleId::new(Box::leak(r.id().to_owned().into_boxed_str()))
.matches(&m.rule)
});
if !known {
skipped += 1;
continue;
}
let Ok(doc) = roxmltree::Document::parse(&m.xml) else {
skipped += 1;
continue;
};
let before = reader.malformed.len();
let invoice = reader.read(doc.root_element());
if reader.malformed.len() > before {
skipped += 1;
continue;
}
let report = m.profile.validate(&invoice);
if report.has(&m.rule) == m.must_fire {
agreed += 1;
} else {
let verdict = if m.must_fire {
"must fire; it did not"
} else {
"must not fire; it did"
};
disagreed.push(format!(
"{} [{}] {verdict} — {}",
m.file, m.rule, m.description
));
}
}
let run = agreed + disagreed.len();
eprintln!(
"XRechnung mutation suite\n \
runnable mutations: {}\n \
run: {run}\n \
agreed: {agreed} ({:.1}% of run)\n \
disagreed: {}\n \
skipped: {skipped} (retired by the types, not in the profile, or a value the model refuses)",
all.len(),
100.0 * agreed as f64 / run.max(1) as f64,
disagreed.len(),
);
assert!(
run >= MIN_KOSIT_MUTATIONS,
"only {run} KoSIT mutations ran, against a floor of {MIN_KOSIT_MUTATIONS}. \
`xrechnung-schematron` is pinned to a moving branch, so the suite \
shrinking is a real possibility rather than a hypothetical one."
);
assert!(
disagreed.is_empty(),
"{} of {run} KoSIT mutation assertions disagree with this crate:\n {}",
disagreed.len(),
disagreed.join("\n ")
);
}
const EXAMPLE_DIRS: &[(&str, &str)] = &[
("CEN", "eInvoicing-EN16931/ubl/examples"),
("CEN testfiles", "eInvoicing-EN16931/test/testfiles"),
("Peppol", "peppol-bis-invoice-3/rules/examples"),
(
"Peppol NO",
"peppol-bis-invoice-3/rules/national-examples/NO",
),
(
"Peppol GR",
"peppol-bis-invoice-3/rules/national-examples/GR",
),
];
const NOT_A_VALID_EXAMPLE: &[(&str, &str)] = &[
("issue116.xml", "a bug-report reproduction, not an example"),
(
"GR-base-example-TaxRepresentative.xml",
"no cac:PartyLegalEntity, so BT-27 is absent — fails CEN's own BR-06",
),
];
#[test]
fn the_authorities_example_invoices_are_valid() {
let Some(root) = common::require("published example invoices") else {
return;
};
let mut reader = ubl::Reader::default();
let mut checked = 0usize;
let mut skipped: Vec<String> = Vec::new();
let mut failures: Vec<String> = Vec::new();
for (who, dir) in EXAMPLE_DIRS {
let d = root.join(dir);
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
let mut files: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
.collect();
files.sort();
for path in files {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if let Some((_, why)) = NOT_A_VALID_EXAMPLE.iter().find(|(f, _)| *f == name) {
skipped.push(format!("{name} — {why}"));
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
skipped.push(format!("{name} — not UTF-8"));
continue;
};
let Ok(doc) = roxmltree::Document::parse(&text) else {
skipped.push(format!("{name} — not well-formed XML"));
continue;
};
let root_el = doc.root_element();
if !matches!(root_el.tag_name().name(), "Invoice" | "CreditNote") {
skipped.push(format!("{name} — not a UBL invoice"));
continue;
}
let before = reader.malformed.len();
let invoice = reader.read(root_el);
if reader.malformed.len() > before {
let what = reader.malformed[before..].join(", ");
skipped.push(format!("{name} — value the model refuses: {what}"));
continue;
}
let profile = invoice
.specification_id
.as_deref()
.and_then(en16931::profiles::for_specification_id)
.unwrap_or(&en16931::profiles::EN16931);
let report = profile.validate(&invoice);
checked += 1;
if !report.is_valid() {
let fatal: Vec<String> = report
.fatal()
.map(|f| format!("{} at {}", f.rule, f.path))
.collect();
failures.push(format!(
"{who}/{name} under {}: {}",
profile.id,
fatal.join("; ")
));
}
}
}
eprintln!(
"published example invoices\n \
checked: {checked}\n \
valid: {}\n \
skipped: {}",
checked - failures.len(),
skipped.len(),
);
for s in &skipped {
eprintln!(" skipped: {s}");
}
assert!(
checked >= MIN_EXAMPLE_INVOICES,
"only {checked} published example invoices were checked, against a floor \
of {MIN_EXAMPLE_INVOICES}. spec/ is present, so an example directory \
moved upstream and this oracle quietly stopped being one."
);
assert!(
failures.is_empty(),
"{} published example invoice(s) are rejected by this crate. Each is \
either a rule that is too eager or a gap in the test reader — never a \
defect in the authority's own example:\n {}",
failures.len(),
failures.join("\n ")
);
}