Skip to main content

automapper_validation/validator/
kind.rs

1//! The machine-readable description of what a validation issue *is*.
2//!
3//! Wording lives in [`crate::display`]. A kind carries every fact the wording
4//! needs, so a consumer can say something other than the sentence we ship.
5
6use mig_assembly::StructureDiagnosticKind;
7use serde::{Deserialize, Serialize};
8
9use super::issue::ValidationCategory;
10
11/// Why a condition could not be evaluated.
12///
13/// Any combination of these may be non-empty; each is a list of AHB condition ids.
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct UnresolvedConditions {
16    /// Need an external provider to answer.
17    pub external: Vec<u32>,
18    /// Not determinable from the message data.
19    pub undetermined: Vec<u32>,
20    /// Not present in the rulebook at all.
21    pub missing: Vec<u32>,
22}
23
24impl UnresolvedConditions {
25    /// True when nothing at all could be attributed — the issue has no detail.
26    pub fn is_empty(&self) -> bool {
27        self.external.is_empty() && self.undetermined.is_empty() && self.missing.is_empty()
28    }
29}
30
31/// What kind of problem a [`ValidationIssue`](super::issue::ValidationIssue) reports.
32///
33/// One variant per problem the validator actually emits. `ErrorCodes` lists 22
34/// code strings, but only these are ever constructed; adding a new check means
35/// adding a variant, which the compiler then forces every narrator to handle.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "camelCase")]
38pub enum IssueKind {
39    /// AHB001 — a field the AHB marks mandatory for this PID is absent.
40    MissingRequiredField { field_name: String },
41    /// AHB002 — a segment group is present although its AHB condition does not
42    /// hold (`Muss [96]` without STS+7 ZAP). `group` is its path (`SG4/SG5`);
43    /// `failed` are the conditions that make it false, as for
44    /// [`FieldConditionNotSatisfied`](Self::FieldConditionNotSatisfied).
45    GroupNotAllowed {
46        group: String,
47        #[serde(default, skip_serializing_if = "Vec::is_empty")]
48        failed: Vec<u32>,
49    },
50    /// AHB002 — a segment is present although its own AHB condition does not
51    /// hold (`CAV+Z56` Wärmenutzung, `Muss [239]`, without CAV+Z65 in its
52    /// SG10). `segment` is its path (`SG4/SG8/SG10/CAV`); `failed` as for
53    /// [`GroupNotAllowed`](Self::GroupNotAllowed).
54    SegmentNotAllowed {
55        segment: String,
56        #[serde(default, skip_serializing_if = "Vec::is_empty")]
57        failed: Vec<u32>,
58    },
59    /// AHB003 — the field is present but its AHB condition is not satisfied.
60    /// `failed` are the conditions that make it false (see
61    /// `ConditionExprEvaluator::false_ids_with_ub`): what kind of error it is
62    /// depends on them.
63    FieldConditionNotSatisfied {
64        field_name: String,
65        #[serde(default, skip_serializing_if = "Vec::is_empty")]
66        failed: Vec<u32>,
67    },
68    /// AHB005 — the condition expression could not be fully evaluated.
69    ConditionUnknown {
70        field_name: String,
71        unresolved: UnresolvedConditions,
72    },
73    /// AHB006 — number of codes present in a package is outside `min..max`.
74    PackageCardinality {
75        package_id: String,
76        present: usize,
77        min: usize,
78        max: usize,
79        codes: Vec<String>,
80    },
81    /// AHB007 — a group or segment the AHB allows once per transaction
82    /// (`[2061]` "genau einmal je SG4 IDE (Vorgang)") is sent `count` times in
83    /// one. `path` is the group or segment (`SG4/SG8`, `SG4/STS`).
84    RepeatedInTransaction { path: String, count: usize },
85    /// AHB008 — a group or segment sent another number of times in its group
86    /// than a repetition condition allows there: too often (ORDERS `[2005]`
87    /// "Pro SG29 LIN ist die SG34 RFF+Z09 (Gerätenummer) genau einmal
88    /// anzugeben") or too rarely (UTILMD `[2317]` "mindestens zweimal").
89    /// `path` is the group or segment (`SG29/SG34`), `count` how often the
90    /// group instance holds it.
91    RepetitionCount { path: String, count: usize },
92    /// AHB009 — two parts the AHB makes alternatives are both sent: each is
93    /// `Muss` only where the other is absent
94    /// ([`exclusive_conditions`](crate::eval::exclusive_conditions)), UTILMD
95    /// Strom's PIA+5 (`[81]`) beside the SG10 CCI+11 "Kein Produkt
96    /// zugeordnet" (`[80]`). `first` and `second` are the two (`SG4/SG8/PIA`,
97    /// `SG4/SG8/SG10`), `conditions` the pair that fails. Unlike content that
98    /// is merely superfluous (AHB002), neither can be ignored.
99    ExclusiveAlternatives {
100        first: String,
101        second: String,
102        conditions: Vec<u32>,
103    },
104    /// COD002 — the value is a valid code but not allowed for this PID.
105    CodeNotAllowedForPid { value: String, allowed: Vec<String> },
106    /// COD003 — a response's answer (AJT 4465, STS C556/9013) is not one of the
107    /// codes of the EBD or Codeliste it names beside it (AJT 1082, C556/1131):
108    /// `AJT+ZB5+G_0059`. The AHB lists which lists may be named; the EBD
109    /// document says what they hold.
110    AnswerNotInList {
111        answer: String,
112        list: String,
113        allowed: Vec<String>,
114    },
115    /// FMT001 — the value is longer than its MIG format allows (`an..512`).
116    ValueTooLong {
117        field_name: String,
118        length: usize,
119        max: usize,
120    },
121    /// FMT002 — a data element of numeric format (`n..10`) holds something
122    /// that is not a number (`O5D2DXBG1A`).
123    ValueNotNumeric { field_name: String },
124    /// FMT004 — a DTM value (C507 DE2380) that does not have the shape of the
125    /// format code beside it (DE2379): `DTM+752:20250401:104`, where 104
126    /// `MMWW-MMWW` asks for two month-and-week pairs. `format` is the code.
127    DateNotInFormat { field_name: String, format: String },
128    /// CON001 — a period ends before it begins, or as it begins where it may
129    /// not be empty: `DTM+164` not after the `DTM+163` beside it (APERAK Z34
130    /// "Zeitintervall negativ oder Null"). `begin` and `end` are the two DTMs
131    /// (`DTM+163`); see [`crate::consistency`].
132    IntervalNotPositive { begin: String, end: String },
133    /// CON002 — a reference within a transaction names nothing in it: an
134    /// SG8 `RFF+Z19` (Messlokation) naming none of the transaction's
135    /// `LOC+Z17` (APERAK Z21 "Geschäftsvorfallinterne Referenzierung
136    /// fehlerhaft"). `reference` is the RFF (`RFF+Z19`), `value` the id it
137    /// names, `targets` what it may name (`LOC+Z17`); see
138    /// [`crate::consistency`].
139    ReferenceNotFound {
140        reference: String,
141        value: String,
142        targets: Vec<String>,
143    },
144    /// STR007 — UNT's declared segment count disagrees with the actual count.
145    UntSegmentCountMismatch { declared: usize, actual: usize },
146    /// STR007 — the input holds several messages, so the count is not checkable.
147    UntCountNotVerifiable { unh_count: usize },
148    /// MAP001 — a field of the BO4E input that the EDIFACT rendered from it
149    /// does not carry: no mapping rule of the PID writes it (issue #446).
150    /// Only BO4E validation raises it; `path` is the field's place in the
151    /// input (`stammdaten.marktteilnehmer[0].ansprechpartner`).
152    Bo4eFieldDropped { path: String },
153    /// STR003 / STR008 — a structure diagnostic raised during assembly.
154    ///
155    /// `detail` carries the diagnostic's own message verbatim: some
156    /// diagnostics (e.g. `mig_assembly::Assembler::assemble_with_diagnostics`'s
157    /// `"Assembly failed: {e}"` case) say something not derivable from
158    /// `(kind, segment_id, position)` alone.
159    StructureDiagnostic {
160        kind: StructureDiagnosticKind,
161        segment_id: String,
162        position: usize,
163        detail: String,
164    },
165}
166
167impl IssueKind {
168    /// The stable machine identifier, unchanged from the former `code` field.
169    pub fn code(&self) -> &'static str {
170        match self {
171            Self::MissingRequiredField { .. } => "AHB001",
172            Self::GroupNotAllowed { .. } | Self::SegmentNotAllowed { .. } => "AHB002",
173            Self::FieldConditionNotSatisfied { .. } => "AHB003",
174            Self::ConditionUnknown { .. } => "AHB005",
175            Self::PackageCardinality { .. } => "AHB006",
176            Self::RepeatedInTransaction { .. } => "AHB007",
177            Self::RepetitionCount { .. } => "AHB008",
178            Self::ExclusiveAlternatives { .. } => "AHB009",
179            Self::CodeNotAllowedForPid { .. } => "COD002",
180            Self::AnswerNotInList { .. } => "COD003",
181            Self::ValueTooLong { .. } => "FMT001",
182            Self::ValueNotNumeric { .. } => "FMT002",
183            Self::DateNotInFormat { .. } => "FMT004",
184            Self::IntervalNotPositive { .. } => "CON001",
185            Self::ReferenceNotFound { .. } => "CON002",
186            Self::UntSegmentCountMismatch { .. } | Self::UntCountNotVerifiable { .. } => "STR007",
187            Self::Bo4eFieldDropped { .. } => "MAP001",
188            // One code per kind (#81). An orphaned group segment is a skipped
189            // segment too (its content is equally absent); the kind and
190            // message tell the missing entry segment apart. A segment whose
191            // qualifier no slot at its place accepts is unexpected there.
192            Self::StructureDiagnostic { kind, .. } => match kind {
193                StructureDiagnosticKind::MissingRequiredSegment => "STR001",
194                StructureDiagnosticKind::MaxRepetitionsExceeded => "STR002",
195                StructureDiagnosticKind::UnexpectedSegment
196                | StructureDiagnosticKind::UnrecognizedQualifier => "STR003",
197                StructureDiagnosticKind::MissingRequiredGroup => "STR005",
198                StructureDiagnosticKind::GroupMaxRepetitionsExceeded => "STR006",
199                StructureDiagnosticKind::SkippedUnknownSegment
200                | StructureDiagnosticKind::OrphanedGroupSegment => "STR008",
201            },
202        }
203    }
204
205    /// The issue's category. Derived, so it can never disagree with the kind.
206    pub fn category(&self) -> ValidationCategory {
207        match self {
208            Self::MissingRequiredField { .. }
209            | Self::GroupNotAllowed { .. }
210            | Self::SegmentNotAllowed { .. }
211            | Self::FieldConditionNotSatisfied { .. }
212            | Self::ConditionUnknown { .. }
213            | Self::PackageCardinality { .. }
214            | Self::RepeatedInTransaction { .. }
215            | Self::RepetitionCount { .. }
216            | Self::ExclusiveAlternatives { .. }
217            | Self::IntervalNotPositive { .. }
218            | Self::ReferenceNotFound { .. } => ValidationCategory::Ahb,
219            Self::CodeNotAllowedForPid { .. } | Self::AnswerNotInList { .. } => {
220                ValidationCategory::Code
221            }
222            Self::UntSegmentCountMismatch { .. }
223            | Self::UntCountNotVerifiable { .. }
224            | Self::Bo4eFieldDropped { .. }
225            | Self::StructureDiagnostic { .. } => ValidationCategory::Structure,
226            Self::ValueTooLong { .. }
227            | Self::ValueNotNumeric { .. }
228            | Self::DateNotInFormat { .. } => ValidationCategory::Format,
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use mig_assembly::StructureDiagnosticKind;
237
238    #[test]
239    fn code_matches_the_legacy_error_code_for_every_kind() {
240        let cases: Vec<(IssueKind, &str)> = vec![
241            (
242                IssueKind::MissingRequiredField {
243                    field_name: "f".into(),
244                },
245                "AHB001",
246            ),
247            (
248                IssueKind::GroupNotAllowed {
249                    group: "SG4/SG5".into(),
250                    failed: Vec::new(),
251                },
252                "AHB002",
253            ),
254            (
255                IssueKind::SegmentNotAllowed {
256                    segment: "SG4/SG8/SG10/CAV".into(),
257                    failed: Vec::new(),
258                },
259                "AHB002",
260            ),
261            (
262                IssueKind::FieldConditionNotSatisfied {
263                    field_name: "f".into(),
264                    failed: vec![],
265                },
266                "AHB003",
267            ),
268            (
269                IssueKind::ConditionUnknown {
270                    field_name: "f".into(),
271                    unresolved: UnresolvedConditions::default(),
272                },
273                "AHB005",
274            ),
275            (
276                IssueKind::PackageCardinality {
277                    package_id: "1P0..1".into(),
278                    present: 2,
279                    min: 0,
280                    max: 1,
281                    codes: vec![],
282                },
283                "AHB006",
284            ),
285            (
286                IssueKind::RepetitionCount {
287                    path: "SG29/SG34".into(),
288                    count: 2,
289                },
290                "AHB008",
291            ),
292            (
293                IssueKind::ExclusiveAlternatives {
294                    first: "SG4/SG8/PIA".into(),
295                    second: "SG4/SG8/SG10".into(),
296                    conditions: vec![80, 81],
297                },
298                "AHB009",
299            ),
300            (
301                IssueKind::CodeNotAllowedForPid {
302                    value: "X".into(),
303                    allowed: vec![],
304                },
305                "COD002",
306            ),
307            (
308                IssueKind::AnswerNotInList {
309                    answer: "ZB5".into(),
310                    list: "G_0059".into(),
311                    allowed: vec![],
312                },
313                "COD003",
314            ),
315            (
316                IssueKind::ValueTooLong {
317                    field_name: "f".into(),
318                    length: 600,
319                    max: 512,
320                },
321                "FMT001",
322            ),
323            (
324                IssueKind::UntSegmentCountMismatch {
325                    declared: 30,
326                    actual: 31,
327                },
328                "STR007",
329            ),
330            (IssueKind::UntCountNotVerifiable { unh_count: 2 }, "STR007"),
331            (
332                IssueKind::Bo4eFieldDropped {
333                    path: "stammdaten.x".into(),
334                },
335                "MAP001",
336            ),
337            (
338                IssueKind::StructureDiagnostic {
339                    kind: StructureDiagnosticKind::SkippedUnknownSegment,
340                    segment_id: "CCI".into(),
341                    position: 15,
342                    detail: "irrelevant to the code".into(),
343                },
344                "STR008",
345            ),
346            (
347                IssueKind::StructureDiagnostic {
348                    kind: StructureDiagnosticKind::UnexpectedSegment,
349                    segment_id: "CCI".into(),
350                    position: 15,
351                    detail: "irrelevant to the code".into(),
352                },
353                "STR003",
354            ),
355        ];
356        for (kind, expected) in cases {
357            assert_eq!(kind.code(), expected, "wrong code for {kind:?}");
358        }
359    }
360
361    /// Every structure diagnostic keeps its own code (#81): a repetition
362    /// overrun must not surface as "unexpected segment".
363    #[test]
364    fn each_structure_kind_has_its_own_code() {
365        use StructureDiagnosticKind as K;
366        for (kind, expected) in [
367            (K::MissingRequiredSegment, "STR001"),
368            (K::MaxRepetitionsExceeded, "STR002"),
369            (K::UnexpectedSegment, "STR003"),
370            (K::UnrecognizedQualifier, "STR003"),
371            (K::MissingRequiredGroup, "STR005"),
372            (K::GroupMaxRepetitionsExceeded, "STR006"),
373            (K::SkippedUnknownSegment, "STR008"),
374            (K::OrphanedGroupSegment, "STR008"),
375        ] {
376            let issue = IssueKind::StructureDiagnostic {
377                kind,
378                segment_id: "RFF".into(),
379                position: 1,
380                detail: String::new(),
381            };
382            assert_eq!(issue.code(), expected, "{kind:?}");
383        }
384    }
385
386    #[test]
387    fn category_is_derived_from_the_kind() {
388        assert_eq!(
389            IssueKind::MissingRequiredField {
390                field_name: "f".into()
391            }
392            .category(),
393            ValidationCategory::Ahb
394        );
395        assert_eq!(
396            IssueKind::CodeNotAllowedForPid {
397                value: "X".into(),
398                allowed: vec![]
399            }
400            .category(),
401            ValidationCategory::Code
402        );
403        assert_eq!(
404            IssueKind::UntSegmentCountMismatch {
405                declared: 1,
406                actual: 2
407            }
408            .category(),
409            ValidationCategory::Structure
410        );
411    }
412}