use std::borrow::Cow;
use crate::validation::{Finding, Severity, Source, ValidationReport};
pub const SCHEMA: &str = "en16931-report/2";
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub schema: Cow<'static, str>,
pub valid: bool,
pub profile: Option<String>,
pub edition: Cow<'static, str>,
pub rules_checked: usize,
pub attribution: Cow<'static, str>,
pub suppressed: Vec<String>,
pub findings: Vec<Entry>,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub rule: String,
pub severity: Cow<'static, str>,
pub source: Cow<'static, str>,
pub location: String,
pub text: String,
pub expected: Option<String>,
pub actual: Option<String>,
pub hint: Option<String>,
}
const fn severity_name(s: Severity) -> &'static str {
match s {
Severity::Fatal => "fatal",
Severity::Warning => "warning",
Severity::Info => "information",
}
}
const fn source_name(s: Source) -> &'static str {
match s {
Source::Both => "standard+artefact",
Source::StandardOnly => "standard",
Source::ArtefactOnly => "artefact",
Source::Crate => "en16931",
}
}
impl Report {
#[must_use]
pub fn of(report: &ValidationReport) -> Self {
Self {
schema: Cow::Borrowed(SCHEMA),
valid: report.is_valid(),
profile: report.profile().map(str::to_owned),
edition: Cow::Borrowed(report.edition().designation()),
rules_checked: report.rules_checked(),
attribution: Cow::Borrowed(crate::ATTRIBUTION),
suppressed: report.suppressed().to_vec(),
findings: report.findings().iter().map(Entry::of).collect(),
}
}
}
impl Entry {
#[must_use]
pub fn of(f: &Finding) -> Self {
let source =
crate::validation::rules::explain(&f.rule).map_or("profile", |r| source_name(r.source));
Self {
rule: f.rule.clone(),
severity: Cow::Borrowed(severity_name(f.severity)),
source: Cow::Borrowed(source),
location: f.path.to_string(),
text: f.message.clone(),
expected: f.detail.as_ref().map(|d| d.expected.clone()),
actual: f.detail.as_ref().map(|d| d.actual.clone()),
hint: f.hint.clone(),
}
}
}
impl From<&ValidationReport> for Report {
fn from(r: &ValidationReport) -> Self {
Self::of(r)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Invoice, profiles, validate};
#[test]
fn the_shape_carries_what_svrl_carries() {
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let out = Report::of(&report);
assert_eq!(out.schema, SCHEMA);
assert!(!out.valid);
assert_eq!(out.profile.as_deref(), Some("XRechnung 3.0"));
assert_eq!(out.edition, "EN 16931-1:2017+A1:2019");
assert!(out.rules_checked > 0);
assert_eq!(out.attribution, crate::ATTRIBUTION);
let e = out.findings.first().expect("findings");
assert!(!e.rule.is_empty());
assert!(matches!(&*e.severity, "fatal" | "warning" | "information"));
assert!(!e.location.is_empty(), "SVRL's `location`, semantically");
assert!(!e.text.is_empty(), "SVRL's `svrl:text`");
}
#[test]
fn a_core_report_names_no_profile() {
let out = Report::of(&validate(&Invoice::default()));
assert_eq!(out.profile, None);
assert_eq!(out.edition, "EN 16931-1:2017+A1:2019");
}
#[test]
fn suppressions_travel_with_the_report() {
let report = crate::validation::Check::new(&profiles::EN16931)
.without("BR-CO-26")
.run(&Invoice::default());
let out = Report::of(&report);
assert_eq!(out.suppressed, ["BR-CO-26"]);
let clean = Report::of(&validate(&Invoice::default()));
assert!(clean.suppressed.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn a_report_survives_a_round_trip_through_json() {
let report = profiles::XRECHNUNG.validate(&Invoice::default());
let out = Report::of(&report);
let json = serde_json::to_string(&out).expect("serialise");
let back: Report = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, out);
assert_eq!(back.schema, SCHEMA);
assert_eq!(back.findings.len(), report.findings().len());
}
#[cfg(feature = "serde")]
#[test]
fn an_unknown_schema_version_is_visible_to_a_reader() {
let json = serde_json::to_string(&Report::of(&validate(&Invoice::default())))
.expect("serialise")
.replace(SCHEMA, "en16931-report/99");
let back: Report = serde_json::from_str(&json).expect("deserialise");
assert_ne!(back.schema, SCHEMA, "a reader can compare and refuse");
}
#[test]
fn provenance_is_carried_per_finding() {
let out = Report::of(&validate(&Invoice::default()));
let br = out
.findings
.iter()
.find(|e| e.rule.starts_with("BR-"))
.expect("a CEN rule fired");
assert!(
matches!(&*br.source, "standard+artefact" | "standard" | "artefact"),
"{}",
br.source
);
}
}