Skip to main content

automapper_validation/
syntax.rs

1//! The MIG syntax check a CONTRL reports: one message against its MIG alone.
2//!
3//! No Prüfidentifikator, AHB or condition takes part. A CONTRL reports syntax
4//! errors only (CONTRL AHB 1.0 §2.1); a code wrong only in its context is an
5//! AHB matter and goes to the APERAK. What is checked:
6//!
7//! - **UNH and UNT** (UCM): their elements, the segment count (UNT 0074, code
8//!   29) and the message reference (UNT 0062 against UNH 0062, code 28). An
9//!   error here stops the check of that message: nothing else is reported.
10//! - **Segments** (UCS): a segment the MIG marks `M`/`R` missing (13), one the
11//!   MIG has no place for (15), more data elements than the MIG defines (16),
12//!   a segment or group repeating beyond its `MaxRep` (35, 36).
13//! - **Data elements** (UCD): an `M`/`R` element or component empty (13), a
14//!   value outside the MIG's code list (12), too many components (16), a bad
15//!   decimal notation (19), characters outside the syntax level (21), a
16//!   letter in a numeric element (37), no digit before the decimal mark (38),
17//!   too long (39), too short (40).
18//!
19//! The codes are those the CONTRL MIG 2.0b allows in UCM/UCS/UCD 0085. Every
20//! finding carries its segment counter from UNH = 1 (UCS 0096); a missing
21//! segment or group is reported at its predecessor. Element positions follow
22//! S011: the segment tag is 0098 = 1, the first component 0104 = 1.
23//!
24//! The message is placed with the assembler on the MIG with every segment
25//! number kept, i.e. as if a Prüfidentifikator allowed all the MIG allows.
26
27use std::collections::{HashMap, HashSet};
28
29use mig_assembly::assembler::{AssembledGroup, AssembledSegment, Assembler, AssemblerConfig};
30use mig_assembly::{pid_filter, structure_check, StructureDiagnosticKind};
31use mig_types::schema::common::CodeDefinition;
32use mig_types::schema::mig::{MigDataElement, MigSchema, MigSegment, MigSegmentGroup};
33use mig_types::segment::OwnedSegment;
34use serde::{Deserialize, Serialize};
35
36/// Which CONTRL segment reports a finding.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub enum SyntaxScope {
39    /// UCM: an error in UNH or UNT.
40    Message,
41    /// UCS: an error of a whole segment (0085 in UCS).
42    Segment,
43    /// UCD: an error of a data element in a segment.
44    Element,
45}
46
47/// One syntax error of a message.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct SyntaxFinding {
50    /// The CONTRL segment that reports it.
51    pub scope: SyntaxScope,
52    /// The syntax error code, DE0085 (`"13"`, `"35"`, …).
53    pub code: String,
54    /// The segment the error is in; for a missing one, the missing segment
55    /// (a missing group: its first segment).
56    pub segment_tag: String,
57    /// The segment counter within the message, UNH = 1 (UCS 0096). A missing
58    /// segment or group is reported at its predecessor.
59    pub message_segment_number: u32,
60    /// The data element's position in the segment, the tag being 1 (0098).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub element_position: Option<u32>,
63    /// The component's position in its composite, from 1 (0104).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub component_position: Option<u32>,
66    /// What is wrong, for people.
67    pub detail: String,
68}
69
70/// Values accepted beyond the MIG's own code lists, by MIG segment `Number`,
71/// element index and component index (0-based). A MIG's code list can lag
72/// its AHBs — a Prüfidentifikator an AHB correction adds, an answer code an
73/// AHB takes from a Codeliste the MIG predates — and what an AHB allows is
74/// never a syntax error.
75pub type AcceptedCodes = HashMap<(String, usize, usize), Accepted>;
76
77/// What an element accepts beyond its MIG code list.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct Accepted {
80    /// Any value: an AHB leaves the element open.
81    pub any: bool,
82    /// These codes.
83    pub codes: HashSet<String>,
84}
85
86impl Accepted {
87    fn accepts(&self, value: &str) -> bool {
88        self.any || self.codes.contains(value)
89    }
90}
91
92/// The character repertoire of a syntax level (UNB S001 0001).
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Charset {
95    /// UNOA: upper-case letters, digits, space and a few signs.
96    Unoa,
97    /// UNOB: printable ASCII.
98    Unob,
99    /// UNOC: ISO 8859-1, the energy market's level.
100    Unoc,
101    /// Any other level: every character but control characters.
102    Other,
103}
104
105impl Charset {
106    /// The repertoire of syntax identifier `id` (`"UNOC"`).
107    pub fn from_syntax_identifier(id: &str) -> Self {
108        match id {
109            "UNOA" => Self::Unoa,
110            "UNOB" => Self::Unob,
111            "UNOC" => Self::Unoc,
112            _ => Self::Other,
113        }
114    }
115
116    fn allows(self, c: char) -> bool {
117        match self {
118            Self::Unoa => {
119                c.is_ascii_uppercase() || c.is_ascii_digit() || " .,-()/='+:?!\"%&*;<>".contains(c)
120            }
121            Self::Unob => (' '..='~').contains(&c),
122            Self::Unoc => (' '..='~').contains(&c) || ('\u{A0}'..='\u{FF}').contains(&c),
123            Self::Other => !c.is_control(),
124        }
125    }
126}
127
128/// The interchange's syntax settings a value is judged by.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct SyntaxSettings {
131    pub charset: Charset,
132    /// The decimal mark (UNA, default `.`).
133    pub decimal_mark: char,
134}
135
136impl Default for SyntaxSettings {
137    fn default() -> Self {
138        Self {
139            charset: Charset::Unoc,
140            decimal_mark: '.',
141        }
142    }
143}
144
145/// Check one message against `mig`.
146///
147/// `segments` are the message's segments as the assembler takes them for
148/// `mig` (`MessageChunk::segments_for_mig`): UNH…UNT, with the envelope
149/// before them where the MIG covers it. Only UNH…UNT is judged.
150pub fn check_message_syntax(
151    segments: &[OwnedSegment],
152    mig: &MigSchema,
153    settings: SyntaxSettings,
154    accepted: &AcceptedCodes,
155) -> Vec<SyntaxFinding> {
156    let Some(unh_index) = segments.iter().position(|s| s.id == "UNH") else {
157        return Vec::new();
158    };
159    let unh = &segments[unh_index];
160    let unh_number = unh.segment_number;
161    let counter = |segment_number: u32| segment_number.saturating_sub(unh_number) + 1;
162    let unt_index = segments.iter().rposition(|s| s.id == "UNT");
163
164    // UCM: UNH and UNT. An error here stops the message.
165    let mut out = Vec::new();
166    for seg in [Some(unh_index), unt_index]
167        .into_iter()
168        .flatten()
169        .map(|i| &segments[i])
170    {
171        if let Some(slot) = mig.segments.iter().find(|s| s.id == seg.id) {
172            check_elements(
173                seg,
174                slot,
175                counter(seg.segment_number),
176                settings,
177                accepted,
178                &mut out,
179            );
180        }
181    }
182    match unt_index {
183        Some(i) => {
184            let unt = &segments[i];
185            let count = (i - unh_index + 1).to_string();
186            if !unt.get_element(0).is_empty() && unt.get_element(0) != count {
187                out.push(finding(
188                    SyntaxScope::Message,
189                    "29",
190                    unt,
191                    counter(unt.segment_number),
192                    Some((2, None)),
193                    format!(
194                        "UNT counts {} segments, the message has {count}",
195                        unt.get_element(0)
196                    ),
197                ));
198            }
199            if !unt.get_element(1).is_empty() && unt.get_element(1) != unh.get_element(0) {
200                out.push(finding(
201                    SyntaxScope::Message,
202                    "28",
203                    unt,
204                    counter(unt.segment_number),
205                    Some((3, None)),
206                    format!(
207                        "UNT names message '{}', UNH '{}'",
208                        unt.get_element(1),
209                        unh.get_element(0)
210                    ),
211                ));
212            }
213        }
214        None => out.push(SyntaxFinding {
215            scope: SyntaxScope::Message,
216            code: "13".into(),
217            segment_tag: "UNT".into(),
218            message_segment_number: counter(
219                segments.last().map_or(unh_number, |s| s.segment_number),
220            ),
221            element_position: None,
222            component_position: None,
223            detail: "UNT is missing".into(),
224        }),
225    }
226    for f in &mut out {
227        f.scope = SyntaxScope::Message;
228    }
229    if !out.is_empty() {
230        return out;
231    }
232
233    // Place the message on the MIG with every segment number kept. The
234    // assembler can merge a group's variants or keep them apart; each loses
235    // some messages (merged: the order within a variant, UTILTS SEQ+Z43 DTM
236    // RFF; apart: ORDERS' UNS after its LIN groups). A message fits the MIG
237    // when some placement fits it, so the one with fewer findings is taken.
238    let numbers = all_numbers(mig);
239    let unmerged = pid_filter::filter_mig_for_pid_unmerged(mig, &numbers);
240    let place = |assembly_mig: &MigSchema| {
241        let assembler = Assembler::with_config(
242            assembly_mig,
243            AssemblerConfig {
244                strict_code_matching: true,
245                skip_unknown_segments: true,
246                ..Default::default()
247            },
248        );
249        let (tree, mut diagnostics) = assembler.assemble_with_diagnostics(segments);
250        diagnostics.extend(structure_check::check_structure(&tree, &unmerged));
251        (tree, diagnostics)
252    };
253    let merged = place(&pid_filter::filter_mig_for_pid(mig, &numbers));
254    let apart = place(&pid_filter::filter_mig_variant_aware(mig, &numbers));
255    let (tree, diagnostics) = if apart.1.len() < merged.1.len() {
256        apart
257    } else {
258        merged
259    };
260
261    let first_segments = group_first_segments(mig);
262    let within =
263        |n: u32| n >= unh_number && unt_index.map_or(true, |i| n <= segments[i].segment_number);
264    let mut reported = HashSet::new();
265    for diag in diagnostics {
266        let n = u32::try_from(diag.position).unwrap_or(0);
267        if !within(n) {
268            continue;
269        }
270        let (code, tag) = match diag.kind {
271            StructureDiagnosticKind::MissingRequiredSegment => ("13", diag.segment_id.clone()),
272            StructureDiagnosticKind::MissingRequiredGroup => (
273                "13",
274                first_segments
275                    .get(diag.segment_id.as_str())
276                    .cloned()
277                    .unwrap_or(diag.segment_id.clone()),
278            ),
279            StructureDiagnosticKind::MaxRepetitionsExceeded => ("35", diag.segment_id.clone()),
280            StructureDiagnosticKind::GroupMaxRepetitionsExceeded => (
281                "36",
282                first_segments
283                    .get(diag.segment_id.as_str())
284                    .cloned()
285                    .unwrap_or(diag.segment_id.clone()),
286            ),
287            _ => ("15", diag.segment_id.clone()),
288        };
289        // The assembler and the structure check can both see one defect.
290        if !reported.insert((code, tag.clone(), n)) {
291            continue;
292        }
293        out.push(SyntaxFinding {
294            scope: SyntaxScope::Segment,
295            code: code.into(),
296            segment_tag: tag,
297            message_segment_number: counter(n),
298            element_position: None,
299            component_position: None,
300            detail: diag.message,
301        });
302    }
303
304    // UCD: every segment between UNH and UNT against its slot, each group
305    // repetition judged by its own MIG variant.
306    let mut root: Vec<&AssembledSegment> = tree.segments.iter().collect();
307    root.extend(tree.inter_group_segments.values().flatten());
308    let mut walk = ElementWalk {
309        settings,
310        accepted,
311        within: &within,
312        counter: &counter,
313        out: &mut out,
314    };
315    walk.segments(&root, &unmerged.segments);
316    walk.groups(&tree.groups, &unmerged.segment_groups);
317
318    // A segment whose only slot it fills with a wrong qualifier is reported
319    // once, as that value (UCD 12), not also as out of place (UCS 15).
320    let wrong_codes: HashSet<u32> = out
321        .iter()
322        .filter(|f| f.scope == SyntaxScope::Element && f.code == "12")
323        .map(|f| f.message_segment_number)
324        .collect();
325    out.retain(|f| {
326        !(f.scope == SyntaxScope::Segment
327            && f.code == "15"
328            && wrong_codes.contains(&f.message_segment_number))
329    });
330
331    out.sort_by_key(|f| {
332        (
333            f.message_segment_number,
334            f.element_position,
335            f.component_position,
336        )
337    });
338    out
339}
340
341fn finding(
342    scope: SyntaxScope,
343    code: &str,
344    seg: &OwnedSegment,
345    message_segment_number: u32,
346    at: Option<(u32, Option<u32>)>,
347    detail: String,
348) -> SyntaxFinding {
349    SyntaxFinding {
350        scope,
351        code: code.into(),
352        segment_tag: seg.id.clone(),
353        message_segment_number,
354        element_position: at.map(|(e, _)| e),
355        component_position: at.and_then(|(_, c)| c),
356        detail,
357    }
358}
359
360/// Every segment `Number` of `mig`: the filter then keeps all of it.
361fn all_numbers(mig: &MigSchema) -> HashSet<String> {
362    fn groups(list: &[MigSegmentGroup], out: &mut HashSet<String>) {
363        for g in list {
364            out.extend(g.segments.iter().filter_map(|s| s.number.clone()));
365            groups(&g.nested_groups, out);
366        }
367    }
368    let mut out: HashSet<String> = mig
369        .segments
370        .iter()
371        .filter_map(|s| s.number.clone())
372        .collect();
373    groups(&mig.segment_groups, &mut out);
374    out
375}
376
377/// Group id → the tag of its first segment (a missing group is reported as
378/// that segment).
379fn group_first_segments(mig: &MigSchema) -> HashMap<&str, String> {
380    fn walk<'m>(list: &'m [MigSegmentGroup], out: &mut HashMap<&'m str, String>) {
381        for g in list {
382            if let Some(first) = g.segments.first() {
383                out.entry(g.id.as_str()).or_insert_with(|| first.id.clone());
384            }
385            walk(&g.nested_groups, out);
386        }
387    }
388    let mut out = HashMap::new();
389    walk(&mig.segment_groups, &mut out);
390    out
391}
392
393/// The element check over the assembled tree, by MIG variant.
394struct ElementWalk<'a> {
395    settings: SyntaxSettings,
396    accepted: &'a AcceptedCodes,
397    within: &'a dyn Fn(u32) -> bool,
398    counter: &'a dyn Fn(u32) -> u32,
399    out: &'a mut Vec<SyntaxFinding>,
400}
401
402impl ElementWalk<'_> {
403    fn groups(&mut self, present: &[AssembledGroup], variants: &[MigSegmentGroup]) {
404        for group in present {
405            let same_id: Vec<&MigSegmentGroup> =
406                variants.iter().filter(|v| v.id == group.group_id).collect();
407            for rep in &group.repetitions {
408                // A repetition no variant claims is the structure check's
409                // finding (UCS 15); its elements have no slots to be judged by.
410                // Of several variants with its entry qualifier, the one it
411                // fits best.
412                let candidates: Vec<&MigSegmentGroup> = match same_id.as_slice() {
413                    [only] => vec![*only],
414                    _ => rep.segments.first().map_or(Vec::new(), |entry| {
415                        same_id
416                            .iter()
417                            .copied()
418                            .filter(|v| {
419                                v.entry_qualifier().is_some_and(|q| {
420                                    q.tag == entry.tag && q.matches(&entry.elements)
421                                })
422                            })
423                            .collect()
424                    }),
425                };
426                let mut best: Option<Vec<SyntaxFinding>> = None;
427                for variant in candidates {
428                    let mut found = Vec::new();
429                    let mut walk = ElementWalk {
430                        settings: self.settings,
431                        accepted: self.accepted,
432                        within: self.within,
433                        counter: self.counter,
434                        out: &mut found,
435                    };
436                    let own: Vec<&AssembledSegment> = rep.segments.iter().collect();
437                    walk.segments(&own, &variant.segments);
438                    walk.groups(&rep.child_groups, &variant.nested_groups);
439                    if best.as_ref().map_or(true, |b| found.len() < b.len()) {
440                        best = Some(found);
441                    }
442                }
443                self.out.extend(best.unwrap_or_default());
444            }
445        }
446    }
447
448    fn segments(&mut self, present: &[&AssembledSegment], slots: &[MigSegment]) {
449        for seg in present {
450            let Some(n) = seg.segment_number else {
451                continue;
452            };
453            if seg.tag == "UNH" || seg.tag == "UNT" || !(self.within)(n) {
454                continue;
455            }
456            let owned = OwnedSegment {
457                id: seg.tag.clone(),
458                elements: seg.elements.clone(),
459                segment_number: n,
460            };
461            // Of the slots `seg` may fill, the one it fits best: a group can
462            // hold several slots of one tag and qualifier (DTM+Z21 by day and
463            // by minute), and only one of them need take its values.
464            let mut best: Option<Vec<SyntaxFinding>> = None;
465            for slot in slots_for(seg, slots) {
466                let mut found = Vec::new();
467                check_elements(
468                    &owned,
469                    slot,
470                    (self.counter)(n),
471                    self.settings,
472                    self.accepted,
473                    &mut found,
474                );
475                if best.as_ref().map_or(true, |b| found.len() < b.len()) {
476                    best = Some(found);
477                }
478            }
479            self.out.extend(best.unwrap_or_default());
480        }
481    }
482}
483
484/// The slots of `slots` that `seg` may fill: those of its tag whose
485/// qualifier it carries, else those of its tag without a qualifier. None
486/// when every slot of its tag names qualifiers and `seg` carries none of
487/// them: the structure check reports that segment (UCS 15) — unless there is
488/// only one slot, whose qualifier is then judged as a value (UCD 12).
489fn slots_for<'m>(seg: &AssembledSegment, slots: &'m [MigSegment]) -> Vec<&'m MigSegment> {
490    let tagged: Vec<&MigSegment> = slots.iter().filter(|s| s.id == seg.tag).collect();
491    let carries = |slot: &MigSegment| {
492        qualifier(slot).map(|(e, c, codes)| {
493            seg.elements
494                .get(e)
495                .and_then(|el| el.get(c))
496                .is_some_and(|v| codes.contains(v))
497        })
498    };
499    let qualified: Vec<&MigSegment> = tagged
500        .iter()
501        .copied()
502        .filter(|slot| carries(slot) == Some(true))
503        .collect();
504    if !qualified.is_empty() {
505        return qualified;
506    }
507    let open: Vec<&MigSegment> = tagged
508        .iter()
509        .copied()
510        .filter(|slot| carries(slot).is_none())
511        .collect();
512    if !open.is_empty() {
513        return open;
514    }
515    match tagged.as_slice() {
516        [only] => vec![*only],
517        _ => Vec::new(),
518    }
519}
520
521/// A MIG example code (`XYZ`) stands for any value.
522fn is_placeholder(code: &CodeDefinition) -> bool {
523    code.name == "Beispielcode" || code.value == "XYZ"
524}
525
526/// The real codes of an element; empty when it takes any value.
527fn codes(d: &MigDataElement) -> Vec<&str> {
528    if d.codes.iter().any(is_placeholder) {
529        return Vec::new();
530    }
531    d.codes
532        .iter()
533        .map(|c| c.value.as_str())
534        .filter(|v| !v.is_empty())
535        .collect()
536}
537
538/// A slot's qualifier: its first coded element, as (element, component, codes).
539fn qualifier(slot: &MigSegment) -> Option<(usize, usize, Vec<String>)> {
540    let simple = slot.data_elements.iter().map(|d| (d.position, 0, d));
541    let composite = slot.composites.iter().flat_map(|c| {
542        c.data_elements
543            .iter()
544            .map(move |d| (c.position, d.position, d))
545    });
546    simple
547        .chain(composite)
548        .filter(|(_, _, d)| !codes(d).is_empty())
549        .min_by_key(|(e, c, _)| (*e, *c))
550        .map(|(e, c, d)| (e, c, codes(d).into_iter().map(String::from).collect()))
551}
552
553fn required(spec: Option<&str>, std: Option<&str>) -> bool {
554    matches!(spec.or(std).map(str::trim), Some("M" | "R"))
555}
556
557/// Check `seg`'s data elements against `slot`.
558fn check_elements(
559    seg: &OwnedSegment,
560    slot: &MigSegment,
561    counter: u32,
562    settings: SyntaxSettings,
563    accepted: &AcceptedCodes,
564    out: &mut Vec<SyntaxFinding>,
565) {
566    let number = slot.number.as_deref().unwrap_or("");
567    let also = |e: usize, c: usize| accepted.get(&(number.to_string(), e, c));
568    let defined = slot
569        .data_elements
570        .iter()
571        .map(|d| d.position)
572        .chain(slot.composites.iter().map(|c| c.position))
573        .max()
574        .map_or(0, |p| p + 1);
575    let last_filled = seg
576        .elements
577        .iter()
578        .rposition(|e| e.iter().any(|c| !c.is_empty()))
579        .map_or(0, |p| p + 1);
580    if last_filled > defined {
581        out.push(finding(
582            SyntaxScope::Segment,
583            "16",
584            seg,
585            counter,
586            None,
587            format!(
588                "{} has {last_filled} data elements, the MIG defines {defined}",
589                seg.id
590            ),
591        ));
592    }
593
594    for d in &slot.data_elements {
595        let element = seg.elements.get(d.position);
596        let at = d.position as u32 + 2;
597        let value = element.and_then(|e| e.first()).map_or("", String::as_str);
598        if element.is_some_and(|e| e.iter().skip(1).any(|c| !c.is_empty())) {
599            out.push(finding(
600                SyntaxScope::Element,
601                "16",
602                seg,
603                counter,
604                Some((at, None)),
605                format!(
606                    "{} {} is a simple data element but has components",
607                    seg.id, d.id
608                ),
609            ));
610        }
611        check_value(
612            seg,
613            d,
614            value,
615            counter,
616            (at, None),
617            settings,
618            also(d.position, 0),
619            out,
620        );
621    }
622
623    for c in &slot.composites {
624        let at = c.position as u32 + 2;
625        let components: &[String] = seg.elements.get(c.position).map_or(&[], Vec::as_slice);
626        if components.iter().all(String::is_empty) {
627            if required(c.status_spec.as_deref(), c.status_std.as_deref()) {
628                out.push(finding(
629                    SyntaxScope::Element,
630                    "13",
631                    seg,
632                    counter,
633                    Some((at, None)),
634                    format!("{} {} ({}) is missing", seg.id, c.id, c.name),
635                ));
636            }
637            continue;
638        }
639        let defined = c
640            .data_elements
641            .iter()
642            .map(|d| d.position)
643            .max()
644            .map_or(0, |p| p + 1);
645        let last_filled = components
646            .iter()
647            .rposition(|v| !v.is_empty())
648            .map_or(0, |p| p + 1);
649        if last_filled > defined {
650            out.push(finding(
651                SyntaxScope::Element,
652                "16",
653                seg,
654                counter,
655                Some((at, None)),
656                format!(
657                    "{} {} has {last_filled} components, the MIG defines {defined}",
658                    seg.id, c.id
659                ),
660            ));
661        }
662        for d in &c.data_elements {
663            let value = components.get(d.position).map_or("", String::as_str);
664            check_value(
665                seg,
666                d,
667                value,
668                counter,
669                (at, Some(d.position as u32 + 1)),
670                settings,
671                also(c.position, d.position),
672                out,
673            );
674        }
675    }
676}
677
678/// Check one simple or component data element's value.
679fn check_value(
680    seg: &OwnedSegment,
681    d: &MigDataElement,
682    value: &str,
683    counter: u32,
684    at: (u32, Option<u32>),
685    settings: SyntaxSettings,
686    also: Option<&Accepted>,
687    out: &mut Vec<SyntaxFinding>,
688) {
689    let mut push = |code: &str, detail: String| {
690        out.push(finding(
691            SyntaxScope::Element,
692            code,
693            seg,
694            counter,
695            Some(at),
696            detail,
697        ));
698    };
699    let name = format!("{} {} ({})", seg.id, d.id, d.name);
700    if value.is_empty() {
701        if required(d.status_spec.as_deref(), d.status_std.as_deref()) {
702            push("13", format!("{name} is missing"));
703        }
704        return;
705    }
706    if let Some(bad) = value.chars().find(|&c| !settings.charset.allows(c)) {
707        push(
708            "21",
709            format!("{name} holds '{bad}', which its syntax level does not allow"),
710        );
711        return;
712    }
713    if let Some(format) = d.format_spec.as_deref().or(d.format_std.as_deref()) {
714        if let Some((code, detail)) = check_format(value, format, settings.decimal_mark) {
715            push(code, format!("{name} '{value}': {detail}"));
716            return;
717        }
718    }
719    let allowed = codes(d);
720    if !allowed.is_empty() && !allowed.contains(&value) && !also.is_some_and(|a| a.accepts(value)) {
721        push(
722            "12",
723            format!(
724                "{name} '{value}' is none of the MIG's codes {}",
725                allowed.join(", ")
726            ),
727        );
728    }
729}
730
731/// A format `an..35`, `an3`, `n..15`, `n5`, `a3`: its kind, its length, and
732/// whether the length is a maximum.
733fn parse_format(format: &str) -> Option<(&str, usize, bool)> {
734    let format = format.trim();
735    let kind_end = format.find(|c: char| !c.is_ascii_alphabetic())?;
736    let (kind, rest) = format.split_at(kind_end);
737    let (upto, digits) = match rest.strip_prefix("..") {
738        Some(d) => (true, d),
739        None => (false, rest),
740    };
741    Some((kind, digits.parse().ok()?, upto))
742}
743
744/// The UCD code and reason when `value` breaks `format`.
745fn check_format(value: &str, format: &str, decimal_mark: char) -> Option<(&'static str, String)> {
746    let (kind, length, upto) = parse_format(format)?;
747    let counted = match kind {
748        "n" => {
749            let unsigned = value.strip_prefix('-').unwrap_or(value);
750            if let Some(c) = unsigned
751                .chars()
752                .find(|c| !c.is_ascii_digit() && *c != decimal_mark)
753            {
754                return Some(match c {
755                    '.' | ',' => (
756                        "19",
757                        format!("'{c}' is not the decimal mark '{decimal_mark}'"),
758                    ),
759                    _ => ("37", format!("'{c}' in a numeric data element ({format})")),
760                });
761            }
762            let marks = unsigned.matches(decimal_mark).count();
763            if marks > 1 || unsigned.ends_with(decimal_mark) {
764                return Some(("19", format!("not a decimal number ({format})")));
765            }
766            if unsigned.starts_with(decimal_mark) {
767                return Some(("38", "no digit before the decimal mark".to_string()));
768            }
769            unsigned.chars().filter(char::is_ascii_digit).count()
770        }
771        "a" => {
772            if let Some(c) = value.chars().find(char::is_ascii_digit) {
773                return Some((
774                    "37",
775                    format!("'{c}' in an alphabetic data element ({format})"),
776                ));
777            }
778            value.chars().count()
779        }
780        _ => value.chars().count(),
781    };
782    if counted > length {
783        return Some((
784            "39",
785            format!("{counted} characters, {format} allows {length}"),
786        ));
787    }
788    if !upto && counted < length {
789        return Some((
790            "40",
791            format!("{counted} characters, {format} requires {length}"),
792        ));
793    }
794    None
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    #[test]
802    fn formats() {
803        assert_eq!(check_format("12345", "n5", '.'), None);
804        assert_eq!(check_format("1234", "n5", '.').map(|f| f.0), Some("40"));
805        assert_eq!(check_format("123456", "n..5", '.').map(|f| f.0), Some("39"));
806        assert_eq!(check_format("-12.5", "n..3", '.'), None);
807        assert_eq!(check_format(".5", "n..3", '.').map(|f| f.0), Some("38"));
808        assert_eq!(check_format("5.", "n..3", '.').map(|f| f.0), Some("19"));
809        assert_eq!(check_format("1,5", "n..3", '.').map(|f| f.0), Some("19"));
810        assert_eq!(check_format("1A", "n..3", '.').map(|f| f.0), Some("37"));
811        assert_eq!(check_format("Z1", "a..3", '.').map(|f| f.0), Some("37"));
812        assert_eq!(check_format("ÄBC", "an3", '.'), None);
813        assert_eq!(check_format("AB", "an3", '.').map(|f| f.0), Some("40"));
814    }
815
816    #[test]
817    fn charsets() {
818        assert!(Charset::Unoc.allows('ä'));
819        assert!(!Charset::Unoc.allows('€'));
820        assert!(!Charset::Unoc.allows('\n'));
821        assert!(!Charset::Unoa.allows('a'));
822        assert!(Charset::Unob.allows('a'));
823    }
824}