Skip to main content

dvgw_edi/
report.rs

1//! Validation findings.
2
3use std::fmt;
4
5use crate::document::{DvgwDocument, DvgwMessageType};
6
7/// How badly a finding breaks the message.
8///
9/// A typed severity, not a string: `issue.severity == "error"` compiles happily
10/// when it is misspelled and then silently matches nothing.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
14pub enum Severity {
15    /// Advisory; the message is conformant.
16    Info,
17    /// The message is processable but deviates from the Nachrichtenbeschreibung.
18    Warning,
19    /// The message violates a `Muss` row and must be rejected.
20    Error,
21}
22
23impl Severity {
24    /// The lowercase name, for logs and JSON.
25    #[must_use]
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Info => "info",
29            Self::Warning => "warning",
30            Self::Error => "error",
31        }
32    }
33}
34
35impl fmt::Display for Severity {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41/// A single validation finding.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize))]
44#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
45#[non_exhaustive]
46pub struct DvgwIssue {
47    /// How badly this breaks the message.
48    pub severity: Severity,
49    /// What is wrong, in prose.
50    pub message: String,
51    /// The stable rule identifier, e.g. `"DVGW-RFF-Z13"`.
52    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
53    pub rule_id: Option<&'static str>,
54    /// The EDIFACT segment tag the finding is about.
55    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
56    pub segment_tag: Option<&'static str>,
57    /// How to fix it.
58    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
59    pub suggestion: Option<String>,
60}
61
62impl DvgwIssue {
63    pub(crate) fn new(severity: Severity, message: impl Into<String>) -> Self {
64        Self {
65            severity,
66            message: message.into(),
67            rule_id: None,
68            segment_tag: None,
69            suggestion: None,
70        }
71    }
72
73    pub(crate) fn with_rule(mut self, rule_id: &'static str) -> Self {
74        self.rule_id = Some(rule_id);
75        self
76    }
77
78    pub(crate) fn with_segment(mut self, tag: &'static str) -> Self {
79        self.segment_tag = Some(tag);
80        self
81    }
82
83    pub(crate) fn with_suggestion(mut self, text: impl Into<String>) -> Self {
84        self.suggestion = Some(text.into());
85        self
86    }
87}
88
89impl fmt::Display for DvgwIssue {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "[{}]", self.severity)?;
92        if let Some(rule) = self.rule_id {
93            write!(f, " {rule}")?;
94        }
95        if let Some(tag) = self.segment_tag {
96            write!(f, " ({tag})")?;
97        }
98        write!(f, ": {}", self.message)
99    }
100}
101
102/// The result of validating one DVGW message.
103#[derive(Debug, Clone)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize))]
105#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
106#[non_exhaustive]
107pub struct DvgwReport {
108    /// The family that was validated.
109    pub message_type: DvgwMessageType,
110    /// The `BGM` document-name code that was validated.
111    pub document: DvgwDocument,
112    /// The `UNH` message reference.
113    pub message_ref: String,
114    /// All findings, in rule order.
115    pub issues: Vec<DvgwIssue>,
116}
117
118impl DvgwReport {
119    pub(crate) fn new(
120        message_type: DvgwMessageType,
121        document: DvgwDocument,
122        message_ref: String,
123        issues: Vec<DvgwIssue>,
124    ) -> Self {
125        Self {
126            message_type,
127            document,
128            message_ref,
129            issues,
130        }
131    }
132
133    /// `true` when nothing at [`Severity::Error`] was found.
134    #[must_use]
135    pub fn is_valid(&self) -> bool {
136        !self.issues.iter().any(|i| i.severity == Severity::Error)
137    }
138
139    /// The error-severity findings.
140    pub fn errors(&self) -> impl Iterator<Item = &DvgwIssue> {
141        self.issues.iter().filter(|i| i.severity == Severity::Error)
142    }
143
144    /// The warning-severity findings.
145    pub fn warnings(&self) -> impl Iterator<Item = &DvgwIssue> {
146        self.issues
147            .iter()
148            .filter(|i| i.severity == Severity::Warning)
149    }
150
151    /// `Ok(self)` when valid, `Err(self)` otherwise — for `?` at a call site
152    /// that treats a non-conformant message as a failure.
153    ///
154    /// # Errors
155    ///
156    /// Returns `Err(self)` when [`is_valid`](Self::is_valid) is `false`.
157    pub fn result(self) -> Result<Self, Self> {
158        if self.is_valid() { Ok(self) } else { Err(self) }
159    }
160}
161
162impl fmt::Display for DvgwReport {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(
165            f,
166            "{} ({}) {}: {} error(s), {} warning(s)",
167            self.message_type,
168            self.document.code(),
169            self.message_ref,
170            self.errors().count(),
171            self.warnings().count()
172        )
173    }
174}