use core::fmt::Write as _;
use crate::validation::{Severity, ValidationReport};
pub const SVRL_NS: &str = "http://purl.oclc.org/dsdl/svrl";
pub const HINT_DIAGNOSTIC: &str = "en16931-hint";
fn escape(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {}
c => out.push(c),
}
}
}
fn comment(body: &str, out: &mut String) {
out.push_str("<!-- ");
let mut last_was_dash = false;
for c in body.chars() {
match c {
'-' if last_was_dash => {}
'-' => {
last_was_dash = true;
out.push('-');
}
c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {}
c => {
last_was_dash = false;
out.push(c);
}
}
}
out.push_str(" -->");
}
const fn flag(s: Severity) -> &'static str {
match s {
Severity::Fatal => "fatal",
Severity::Warning => "warning",
Severity::Info => "information",
}
}
#[must_use]
pub fn to_svrl(report: &ValidationReport) -> String {
let mut out = String::with_capacity(256 + report.findings().len() * 160);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<svrl:schematron-output xmlns:svrl=\"");
out.push_str(SVRL_NS);
out.push_str("\" title=\"EN 16931");
if let Some(p) = report.profile() {
out.push_str(" — ");
escape(p, &mut out);
}
out.push_str("\" schemaVersion=\"");
escape(report.edition().designation(), &mut out);
out.push_str("\">\n");
out.push_str(" ");
comment(
"Produced by en16931 from the semantic model. `location` is a \
business-term path, not an XPath: there is no source document. `test` \
names the rule; these rules are code, not XPath.",
&mut out,
);
out.push('\n');
out.push_str(" ");
comment(crate::ATTRIBUTION, &mut out);
out.push('\n');
out.push_str(" <svrl:active-pattern name=\"");
escape(report.profile().unwrap_or("EN 16931"), &mut out);
out.push_str("\"/>\n");
for id in report.suppressed() {
out.push_str(" ");
comment(&format!("suppressed, NOT checked: {id}"), &mut out);
out.push('\n');
}
for f in report.findings() {
out.push_str(" <svrl:failed-assert id=\"");
escape(&f.rule, &mut out);
out.push_str("\" flag=\"");
out.push_str(flag(f.severity));
out.push_str("\" location=\"");
escape(&f.path.to_string(), &mut out);
out.push_str("\" test=\"en16931:");
escape(&f.rule, &mut out);
out.push_str("\">\n");
if let Some(h) = &f.hint {
out.push_str(" <svrl:diagnostic-reference diagnostic=\"");
out.push_str(HINT_DIAGNOSTIC);
out.push_str("\">");
escape(h, &mut out);
out.push_str("</svrl:diagnostic-reference>\n");
}
out.push_str(" <svrl:text>");
escape(&f.message, &mut out);
if let Some(d) = &f.detail {
let _ = write!(out, " (expected ");
escape(&d.expected, &mut out);
let _ = write!(out, ", found ");
escape(&d.actual, &mut out);
out.push(')');
}
out.push_str("</svrl:text>\n </svrl:failed-assert>\n");
}
out.push_str("</svrl:schematron-output>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Invoice, profiles, validate};
#[test]
fn it_is_well_formed_and_carries_the_findings() {
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let xml = to_svrl(&report);
assert!(xml.starts_with("<?xml version=\"1.0\""));
assert!(xml.contains("xmlns:svrl=\"http://purl.oclc.org/dsdl/svrl\""));
assert!(xml.contains("schemaVersion=\"EN 16931-1:2017+A1:2019\""));
assert!(xml.contains("<svrl:active-pattern name=\"XRechnung 3.0\"/>"));
assert!(xml.contains("id=\"BR-02\""));
assert!(xml.contains("flag=\"fatal\""));
assert!(xml.contains("location=\"BT-1\""));
assert!(xml.ends_with("</svrl:schematron-output>\n"));
assert_eq!(
xml.matches("<svrl:failed-assert").count(),
report.findings().len()
);
}
#[test]
fn the_attribution_is_present() {
let xml = to_svrl(&validate(&Invoice::default()));
assert!(xml.contains("implementation of the EN 16931-1 semantic data model"));
assert!(xml.contains("© CEN"));
}
#[test]
fn suppressions_are_recorded() {
let report = crate::validation::Check::new(&profiles::EN16931)
.without("BR-CO-26")
.run(&Invoice::default());
let xml = to_svrl(&report);
assert!(xml.contains("suppressed, NOT checked: BR-CO-26"));
}
#[test]
fn the_output_is_well_formed_xml() {
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let xml = to_svrl(&report);
let doc = roxmltree::Document::parse(&xml).expect("well-formed SVRL");
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "schematron-output");
assert_eq!(root.tag_name().namespace(), Some(SVRL_NS));
assert!(
root.attribute("title")
.is_some_and(|t| t.contains("XRechnung"))
);
assert_eq!(
root.attribute("schemaVersion"),
Some("EN 16931-1:2017+A1:2019")
);
let asserts: Vec<_> = root
.children()
.filter(|n| n.has_tag_name((SVRL_NS, "failed-assert")))
.collect();
assert_eq!(asserts.len(), report.findings().len());
for (node, finding) in asserts.iter().zip(report.findings()) {
assert_eq!(node.attribute("id"), Some(finding.rule.as_str()));
assert_eq!(
node.attribute("location"),
Some(finding.path.to_string().as_str())
);
assert_eq!(
node.attribute("test"),
Some(format!("en16931:{}", finding.rule).as_str())
);
assert!(matches!(
node.attribute("flag"),
Some("fatal" | "warning" | "information")
));
let text = node
.children()
.find(|n| n.has_tag_name((SVRL_NS, "text")))
.and_then(|n| n.text())
.unwrap_or_default();
assert!(text.starts_with(&finding.message), "{text:?}");
}
}
#[test]
fn hostile_suppressions_stay_well_formed() {
for id in [
"BR--CO-26", "BR-CO-26--", "BR-CO-26-", "a<b&c>d", "BR\u{7}CO", "----------",
] {
let report = crate::validation::Check::new(&profiles::EN16931)
.without(id)
.run(&Invoice::default());
let xml = to_svrl(&report);
roxmltree::Document::parse(&xml)
.unwrap_or_else(|e| panic!("suppressing {id:?} produced invalid XML: {e}\n{xml}"));
}
}
#[test]
fn comments_are_not_entity_escaped() {
let mut s = String::new();
comment("a & b < c", &mut s);
assert_eq!(s, "<!-- a & b < c -->");
}
#[test]
fn text_is_escaped() {
let mut s = String::new();
escape("a & b < c > d \" e ' f", &mut s);
assert_eq!(s, "a & b < c > d " e ' f");
let mut s = String::new();
escape("a\u{7}b", &mut s);
assert_eq!(s, "ab");
}
#[test]
fn every_rule_text_survives_escaping() {
for r in crate::validation::rules::all() {
let mut s = String::new();
escape(r.text, &mut s);
assert!(!s.contains('<'), "{}", r.id);
assert!(!s.contains('>'), "{}", r.id);
for (i, _) in s.match_indices('&') {
assert!(
s[i..].starts_with("&")
|| s[i..].starts_with("<")
|| s[i..].starts_with(">")
|| s[i..].starts_with(""")
|| s[i..].starts_with("'"),
"{} has a bare ampersand",
r.id
);
}
}
}
}