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}