pub mod profile;
pub mod rules;
use core::fmt;
use crate::bt::{BtId, Path};
use crate::invoice::Invoice;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RuleId(&'static str);
impl RuleId {
#[must_use]
pub const fn new(id: &'static str) -> Self {
Self(id)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
self.0
}
#[must_use]
pub fn matches(self, query: &str) -> bool {
canonical_eq(self.0, query)
}
}
fn canonical_eq(a: &str, b: &str) -> bool {
let (head_a, num_a) = split_trailing_number(a.trim());
let (head_b, num_b) = split_trailing_number(b.trim());
if num_a != num_b {
return false;
}
let (prefix_a, rest_a) = alias_family(head_a);
let (prefix_b, rest_b) = alias_family(head_b);
eq_ignore_case_concat(prefix_a, rest_a, prefix_b, rest_b)
}
fn split_trailing_number(id: &str) -> (&str, Option<u32>) {
match id.rsplit_once('-') {
Some((head, tail))
if !tail.is_empty() && tail.len() <= 9 && tail.bytes().all(|b| b.is_ascii_digit()) =>
{
(head, tail.parse().ok())
}
_ => (id, None),
}
}
fn alias_family(head: &str) -> (&'static str, &str) {
const ALIASES: [(&str, &str); 2] = [("BR-IG", "BR-AF"), ("BR-IP", "BR-AG")];
for (from, to) in ALIASES {
if head.len() >= from.len() && head[..from.len()].eq_ignore_ascii_case(from) {
return (to, &head[from.len()..]);
}
}
("", head)
}
fn eq_ignore_case_concat(a1: &str, a2: &str, b1: &str, b2: &str) -> bool {
let a = a1.bytes().chain(a2.bytes()).map(|b| b.to_ascii_uppercase());
let b = b1.bytes().chain(b2.bytes()).map(|b| b.to_ascii_uppercase());
a.eq(b)
}
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.0)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Fatal,
Warning,
Info,
}
impl core::fmt::Display for Severity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.pad(match self {
Self::Fatal => "fatal",
Self::Warning => "warning",
Self::Info => "information",
})
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Source {
Both,
StandardOnly,
ArtefactOnly,
Crate,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detail {
pub expected: String,
pub actual: String,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub rule: String,
pub severity: Severity,
pub path: Path,
pub message: String,
pub detail: Option<Detail>,
}
impl fmt::Display for Finding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {} — {}", self.rule, self.path, self.message)?;
if let Some(d) = &self.detail {
write!(f, " (expected {}, found {})", d.expected, d.actual)?;
}
Ok(())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ValidationReport {
findings: Vec<Finding>,
checked: usize,
profile: Option<String>,
edition: crate::Edition,
suppressed: Vec<String>,
}
impl ValidationReport {
#[must_use]
pub fn is_valid(&self) -> bool {
!self.findings.iter().any(|f| f.severity == Severity::Fatal)
}
#[must_use]
pub fn findings(&self) -> &[Finding] {
&self.findings
}
pub fn fatal(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Fatal)
}
pub fn warnings(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Warning)
}
pub fn info(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Info)
}
pub fn advisory(&self) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(|f| f.severity != Severity::Fatal)
}
#[must_use]
pub fn rules_checked(&self) -> usize {
self.checked
}
#[must_use]
pub fn profile(&self) -> Option<&str> {
self.profile.as_deref()
}
#[must_use]
pub fn edition(&self) -> crate::Edition {
self.edition
}
#[must_use]
pub fn suppressed(&self) -> &[String] {
&self.suppressed
}
pub(crate) fn attribute_to(&mut self, id: &'static str, edition: crate::Edition) {
self.profile = Some(id.to_owned());
self.edition = edition;
}
#[must_use]
pub fn has(&self, rule: &str) -> bool {
self.findings.iter().any(|f| canonical_eq(&f.rule, rule))
}
pub(crate) fn absorb(&mut self, extra: Vec<Finding>, checked: usize) {
self.findings.extend(extra);
self.checked += checked;
self.findings.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.rule.cmp(&b.rule))
});
}
pub fn into_result(self) -> Result<Self, Self> {
if self.is_valid() { Ok(self) } else { Err(self) }
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"{} validation ({}) — {} rule(s) checked, {} finding(s){}",
self.profile.as_deref().unwrap_or("EN 16931"),
self.edition,
self.checked,
self.findings.len(),
if self.is_valid() {
", valid"
} else {
", INVALID"
}
)?;
if !self.suppressed.is_empty() {
writeln!(
f,
" ⚠ {} rule(s) suppressed and NOT checked: {}",
self.suppressed.len(),
self.suppressed.join(", ")
)?;
}
for finding in &self.findings {
writeln!(f, " {finding}")?;
}
write!(f, " ({})", crate::ATTRIBUTION)
}
}
pub struct Findings<'a> {
out: &'a mut Vec<Finding>,
rule: &'static Rule,
}
impl<'a> Findings<'a> {
#[cfg(test)]
pub(crate) fn for_test(out: &'a mut Vec<Finding>, rule: &'static Rule) -> Self {
Self { out, rule }
}
}
impl Findings<'_> {
pub fn at(&mut self, path: Path) {
self.out.push(Finding {
rule: self.rule.id.as_str().to_owned(),
severity: self.rule.severity,
path,
message: self.rule.text.to_owned(),
detail: None,
});
}
pub fn arithmetic(
&mut self,
path: Path,
expected: impl fmt::Display,
actual: impl fmt::Display,
) {
self.out.push(Finding {
rule: self.rule.id.as_str().to_owned(),
severity: self.rule.severity,
path,
message: self.rule.text.to_owned(),
detail: Some(Detail {
expected: expected.to_string(),
actual: actual.to_string(),
}),
});
}
}
pub struct Rule {
pub id: RuleId,
pub severity: Severity,
pub text: &'static str,
pub terms: &'static [BtId],
pub source: Source,
pub eval: fn(&Invoice, &mut Findings<'_>),
}
impl fmt::Debug for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Rule")
.field("id", &self.id)
.field("severity", &self.severity)
.field("source", &self.source)
.finish_non_exhaustive()
}
}
#[must_use]
pub fn validate_with(invoice: &Invoice, rules: &[&'static Rule]) -> ValidationReport {
let mut findings = Vec::new();
for rule in rules {
let mut sink = Findings {
out: &mut findings,
rule,
};
(rule.eval)(invoice, &mut sink);
}
sort_findings(&mut findings);
ValidationReport {
findings,
checked: rules.len(),
profile: None,
edition: crate::DEFAULT_EDITION,
suppressed: Vec::new(),
}
}
pub(crate) fn validate_with_all<I>(
invoice: &Invoice,
core: I,
extra: &[&'static Rule],
) -> ValidationReport
where
I: Iterator<Item = &'static Rule>,
{
let mut findings = Vec::new();
let mut checked = 0usize;
for rule in core.chain(extra.iter().copied()) {
checked += 1;
let mut sink = Findings {
out: &mut findings,
rule,
};
(rule.eval)(invoice, &mut sink);
}
sort_findings(&mut findings);
ValidationReport {
findings,
checked,
profile: None,
edition: crate::DEFAULT_EDITION,
suppressed: Vec::new(),
}
}
fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.rule.cmp(&b.rule))
});
}
#[must_use]
pub fn validate(invoice: &Invoice) -> ValidationReport {
if invoice.extensions.is_empty() {
let core: Vec<&'static Rule> = rules::CORE
.iter()
.copied()
.filter(|r| r.id.as_str() != "EN-EXT-01")
.collect();
return validate_with(invoice, &core);
}
validate_with(invoice, &rules::CORE)
}
#[derive(Debug, Clone)]
pub struct Check {
profile: &'static profile::Profile,
suppressed: Vec<String>,
}
impl Check {
#[must_use]
pub fn new(profile: &'static profile::Profile) -> Self {
Self {
profile,
suppressed: Vec::new(),
}
}
#[must_use]
pub fn without(mut self, rule: impl Into<String>) -> Self {
self.suppressed.push(rule.into());
self
}
#[must_use]
pub fn suppressions(&self) -> &[String] {
&self.suppressed
}
#[must_use]
pub fn run(&self, invoice: &Invoice) -> ValidationReport {
let mut report = self.profile.validate(invoice);
if self.suppressed.is_empty() {
return report;
}
report
.findings
.retain(|f| !self.suppressed.iter().any(|s| canonical_eq(&f.rule, s)));
report.checked = report.checked.saturating_sub(self.suppressed.len());
report.suppressed.clone_from(&self.suppressed);
report
}
pub fn prove<P>(&self, invoice: Invoice) -> Result<profile::Validated<P>, ProveError>
where
P: profile::ProfileMarker,
{
if !self.suppressed.is_empty() {
return Err(ProveError::Suppressed(self.suppressed.clone()));
}
profile::Validated::new(invoice).map_err(ProveError::Rejected)
}
}
#[derive(Debug)]
pub enum ProveError {
Suppressed(Vec<String>),
Rejected(profile::Rejected),
}
impl fmt::Display for ProveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Suppressed(ids) => write!(
f,
"cannot prove validity: {} rule(s) were suppressed ({}). A proof \
means the whole rule set passed; use `Check::run` for a report.",
ids.len(),
ids.join(", ")
),
Self::Rejected(r) => write!(f, "invoice is not valid:\n{}", r.1),
}
}
}
impl core::error::Error for ProveError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_ids_normalise_padding_case_and_family_aliases() {
assert!(RuleId::new("BR-CO-03").matches("BR-CO-3"));
assert!(RuleId::new("BR-CO-03").matches("br-co-3"));
assert!(RuleId::new("BR-01").matches("BR-1"));
assert!(RuleId::new("BR-S-01").matches("BR-S-1"));
assert!(RuleId::new("BR-AF-01").matches("BR-IG-1"));
assert!(RuleId::new("BR-AG-10").matches("BR-IP-10"));
assert!(!RuleId::new("BR-CO-13").matches("BR-CO-14"));
assert!(!RuleId::new("BR-CO-01").matches("BR-CO-10"));
}
#[test]
fn suffixed_ids_are_left_alone() {
assert!(RuleId::new("BR-DE-23-a").matches("br-de-23-a"));
assert!(!RuleId::new("BR-DE-23-a").matches("BR-DE-23-b"));
}
}