Skip to main content

edifact_rs/
envelope.rs

1//! EDIFACT envelope validation (Story 2.4).
2//!
3//! Validates UNB / UNH / UNT / UNZ envelope segment structure and count
4//! consistency — independently of business-rule (AHB) validation.
5
6use crate::{error::EdifactError, model::Segment};
7
8/// Extracted data from the `UNB` / `UNZ` interchange envelope.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct InterchangeEnvelope {
11    /// Syntax identifier, e.g. `"UNOA"`.
12    pub syntax_identifier: String,
13    /// Interchange sender identification.
14    pub sender_id: String,
15    /// Interchange recipient identification.
16    pub recipient_id: String,
17    /// Interchange date-time string as found in the source.
18    pub datetime: String,
19    /// Interchange control reference.
20    pub control_ref: String,
21    /// Declared message (functional group) count from `UNZ`.
22    pub declared_message_count: u32,
23    /// Actual message count encountered between `UNB` and `UNZ`.
24    pub actual_message_count: u32,
25}
26
27/// Extracted data from a single `UNH` / `UNT` message envelope.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MessageEnvelope {
30    /// Message reference from `UNH` element 0.
31    pub message_ref: String,
32    /// EDIFACT message type, e.g. `"ORDERS"`.
33    pub message_type: String,
34    /// Version number, e.g. `"D"`.
35    pub version: String,
36    /// Release number, e.g. `"11A"`.
37    pub release: String,
38    /// Controlling agency code, e.g. `"UN"`.
39    pub controlling_agency: String,
40    /// Association assigned code (MIG version), e.g. `"FV2510"`.
41    pub association_code: String,
42    /// Declared segment count from `UNT`.
43    pub declared_segment_count: u32,
44    /// Actual segment count between this `UNH` and its `UNT`.
45    pub actual_segment_count: u32,
46}
47
48/// Parsed identifier fields from a `UNH` segment.
49///
50/// Produced by [`parse_unh`].  All string slices borrow from the input bytes
51/// passed to the parser, so they live as long as the original byte buffer.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct MessageIdentifier<'a> {
54    /// EDIFACT message type, e.g. `"ORDERS"`.
55    pub message_type: &'a str,
56    /// Version number, e.g. `"D"`.
57    pub version: &'a str,
58    /// Release number, e.g. `"11A"`.
59    pub release: &'a str,
60    /// Controlling agency code, e.g. `"UN"`.
61    pub controlling_agency: &'a str,
62    /// Association assigned code (MIG version), e.g. `"FV2510"`.
63    pub association_assigned: &'a str,
64}
65
66/// Extract identifier fields from a `UNH` segment.
67///
68/// Returns a [`MessageIdentifier`] borrowing directly from the segment's
69/// component slices — zero allocation.
70///
71/// # Errors
72///
73/// Returns [`EdifactError::MissingRequiredElement`] if element 1 of the `UNH`
74/// segment is absent, or [`EdifactError::MissingRequiredComponent`] if
75/// component 0 of that element (the message type) is absent.
76pub fn parse_unh<'a>(unh: &'a Segment<'a>) -> Result<MessageIdentifier<'a>, EdifactError> {
77    let elem = unh
78        .get_element(1)
79        .ok_or_else(|| EdifactError::MissingRequiredElement {
80            tag: "UNH".to_owned(),
81            element_index: 1,
82        })?;
83    let message_type =
84        elem.get_component(0)
85            .ok_or_else(|| EdifactError::MissingRequiredComponent {
86                tag: "UNH".to_owned(),
87                element_index: 1,
88                component_index: 0,
89            })?;
90    Ok(MessageIdentifier {
91        message_type,
92        version: elem.get_component(1).unwrap_or(""),
93        release: elem.get_component(2).unwrap_or(""),
94        controlling_agency: elem.get_component(3).unwrap_or(""),
95        association_assigned: elem.get_component(4).unwrap_or(""),
96    })
97}
98
99/// Validates the EDIFACT interchange envelope for the given segments.
100///
101/// Checks:
102/// - `UNB` is present (first meaningful segment)
103/// - `UNZ` is present (last segment) with correct message count
104/// - Each `UNH` is paired with a `UNT` carrying a matching segment count
105/// - `UNZ` message count matches the number of `UNH`/`UNT` pairs found
106///
107/// Returns `Ok((interchange_env, message_envs))` on success,
108/// or an [`EdifactError`] on any structural violation.
109///
110/// # Errors
111///
112/// Returns [`EdifactError::FunctionalGroupNotSupported`] if the input contains
113/// `UNG`/`UNE` functional group segments.  Strip them before calling this
114/// function if functional groups are not relevant to your use case.
115///
116/// Returns [`EdifactError::MessageCountMismatch`] or
117/// [`EdifactError::SegmentCountMismatch`] on count discrepancies.
118pub fn validate_envelope(
119    segments: &[Segment<'_>],
120) -> Result<(InterchangeEnvelope, Vec<MessageEnvelope>), EdifactError> {
121    // Functional group segments are not supported.  Detect them early so the
122    // caller gets a clear diagnostic rather than a misleading segment-count
123    // mismatch or `InvalidSegmentForMessage` buried deep in the parse.
124    if let Some(ung_or_une) = segments.iter().find(|s| s.tag == "UNG" || s.tag == "UNE") {
125        return Err(EdifactError::FunctionalGroupNotSupported {
126            offset: ung_or_une.span.start,
127        });
128    }
129
130    let mut interchange_env = extract_interchange(segments)?;
131    let message_envs = extract_messages(segments)?;
132    interchange_env.actual_message_count =
133        u32::try_from(message_envs.len()).map_err(|_| EdifactError::InterchangeTooLarge {
134            count: message_envs.len() as u64,
135        })?;
136
137    // Cross-check UNZ declared count vs. actual UNH/UNT pair count
138    if interchange_env.declared_message_count != interchange_env.actual_message_count {
139        return Err(EdifactError::MessageCountMismatch {
140            expected: interchange_env.declared_message_count,
141            actual: interchange_env.actual_message_count,
142        });
143    }
144
145    // Cross-check each UNT segment count vs. actual count
146    for msg in &message_envs {
147        if msg.declared_segment_count != msg.actual_segment_count {
148            return Err(EdifactError::SegmentCountMismatch {
149                expected: msg.declared_segment_count,
150                actual: msg.actual_segment_count,
151                message_ref: msg.message_ref.clone(),
152            });
153        }
154    }
155
156    Ok((interchange_env, message_envs))
157}
158
159/// Validate the EDIFACT envelope structure and collect **all** errors rather
160/// than stopping at the first failure.
161///
162/// This is the lenient counterpart of [`validate_envelope`].  It attempts
163/// every check independently and accumulates all violations into the returned
164/// `Vec`.  An empty `Vec` means the envelope is valid.
165///
166/// This is particularly useful for diagnostic tooling, linters, or batch
167/// processors where surfacing every problem at once is more helpful than
168/// short-circuiting on the first error.
169///
170/// # Caveats
171///
172/// Because checks build on each other (e.g., count checks require a valid UNB
173/// and UNZ), some secondary errors may be silenced when a prerequisite check
174/// already failed.  Most *independent* checks (control-reference match,
175/// message/segment counts) are run even after the first failure.  However,
176/// if a functional group segment (`UNG`/`UNE`) is detected, the function
177/// returns immediately with only that error — the remaining structure is
178/// ambiguous and running further checks would produce misleading results.
179pub fn validate_envelope_lenient(segments: &[Segment<'_>]) -> Vec<EdifactError> {
180    let mut errors: Vec<EdifactError> = Vec::new();
181
182    // Functional group check is always independent.
183    if let Some(ung_or_une) = segments.iter().find(|s| s.tag == "UNG" || s.tag == "UNE") {
184        errors.push(EdifactError::FunctionalGroupNotSupported {
185            offset: ung_or_une.span.start,
186        });
187        // UNG/UNE makes the rest of the envelope ambiguous — stop early.
188        return errors;
189    }
190
191    // Run the normal path and, if it succeeds, we're done.
192    match validate_envelope(segments) {
193        Ok(_) => {}
194        Err(first) => {
195            errors.push(first);
196
197            // Now try individual sub-checks that are independent of each other.
198            // Interchange envelope checks.
199            if let Ok(mut ie) = extract_interchange(segments) {
200                // extract_interchange succeeded — try message extraction separately.
201                match extract_messages(segments) {
202                    Ok(msgs) => {
203                        ie.actual_message_count = u32::try_from(msgs.len()).unwrap_or(u32::MAX);
204                        if ie.declared_message_count != ie.actual_message_count {
205                            // Only push if not already in errors (the normal path
206                            // may have returned this as the first error).
207                            let dup = EdifactError::MessageCountMismatch {
208                                expected: ie.declared_message_count,
209                                actual: ie.actual_message_count,
210                            };
211                            if !errors.iter().any(|e| e == &dup) {
212                                errors.push(dup);
213                            }
214                        }
215                        for msg in &msgs {
216                            if msg.declared_segment_count != msg.actual_segment_count {
217                                let dup = EdifactError::SegmentCountMismatch {
218                                    expected: msg.declared_segment_count,
219                                    actual: msg.actual_segment_count,
220                                    message_ref: msg.message_ref.clone(),
221                                };
222                                if !errors.iter().any(|e| e == &dup) {
223                                    errors.push(dup);
224                                }
225                            }
226                        }
227                    }
228                    Err(e) => {
229                        if !errors.iter().any(|err| err == &e) {
230                            errors.push(e);
231                        }
232                    }
233                }
234            }
235        }
236    }
237
238    errors
239}
240
241fn extract_interchange(segments: &[Segment<'_>]) -> Result<InterchangeEnvelope, EdifactError> {
242    if segments.first().map(|segment| segment.tag) != Some("UNB") {
243        return Err(EdifactError::MissingSegment {
244            tag: "UNB".to_owned(),
245            expected_position: "first segment of interchange".to_owned(),
246        });
247    }
248
249    if segments.last().map(|segment| segment.tag) != Some("UNZ") {
250        return Err(EdifactError::MissingSegment {
251            tag: "UNZ".to_owned(),
252            expected_position: "last segment of interchange".to_owned(),
253        });
254    }
255
256    let unb = &segments[0];
257    let unz = &segments[segments.len() - 1];
258
259    let syntax_identifier = required_component(unb, 0, 0)?.to_owned();
260
261    let sender_id = required_component(unb, 1, 0)?.to_owned();
262
263    let recipient_id = required_component(unb, 2, 0)?.to_owned();
264
265    // Element 3: date/time composite
266    let date = required_component(unb, 3, 0)?;
267    let time = unb
268        .get_element(3)
269        .and_then(|e| e.get_component(1))
270        .unwrap_or("");
271    let datetime = if time.is_empty() {
272        date.to_owned()
273    } else {
274        format!("{date}:{time}")
275    };
276
277    let control_ref = required_component(unb, 4, 0)?.to_owned();
278    let unz_control_ref = required_component(unz, 1, 0)?;
279    if unz_control_ref != control_ref {
280        return Err(EdifactError::QualifierMismatch {
281            tag: "UNZ".to_owned(),
282            actual: unz_control_ref.to_owned(),
283            expected: control_ref,
284            offset: unz.span.start,
285        });
286    }
287
288    let declared_message_count: u32 =
289        required_component(unz, 0, 0)?
290            .parse()
291            .map_err(|_| EdifactError::InvalidText {
292                offset: unz.span.start,
293            })?;
294
295    Ok(InterchangeEnvelope {
296        syntax_identifier,
297        sender_id,
298        recipient_id,
299        datetime,
300        control_ref,
301        declared_message_count,
302        actual_message_count: 0,
303    })
304}
305
306/// Thin shim that forwards to [`crate::de::required_component`].
307#[inline]
308fn required_component<'a>(
309    segment: &'a Segment<'_>,
310    element_index: usize,
311    component_index: usize,
312) -> Result<&'a str, EdifactError> {
313    crate::de::required_component(segment, element_index, component_index)
314}
315
316fn extract_messages(segments: &[Segment<'_>]) -> Result<Vec<MessageEnvelope>, EdifactError> {
317    let mut messages: Vec<MessageEnvelope> = Vec::new();
318    let mut in_message = false;
319    let mut msg_start_idx: usize = 0;
320    let mut current_unh: Option<&Segment<'_>> = None;
321
322    for (i, seg) in segments[1..segments.len() - 1].iter().enumerate() {
323        match seg.tag {
324            "UNH" => {
325                if in_message {
326                    return Err(EdifactError::InvalidSegmentForMessage {
327                        tag: "UNH".to_owned(),
328                        message_type: "ENVELOPE".to_owned(),
329                        offset: seg.span.start,
330                    });
331                }
332                in_message = true;
333                msg_start_idx = i;
334                current_unh = Some(seg);
335            }
336            "UNT" if in_message => {
337                let unh = current_unh
338                    .take()
339                    .ok_or(EdifactError::InvalidSegmentForMessage {
340                        tag: "UNT".to_owned(),
341                        message_type: "ENVELOPE".to_owned(),
342                        offset: seg.span.start,
343                    })?;
344
345                let message_ref = required_component(unh, 0, 0)?.to_owned();
346
347                let message_type = required_component(unh, 1, 0)?.to_owned();
348                let version = required_component(unh, 1, 1)?.to_owned();
349                let release = required_component(unh, 1, 2)?.to_owned();
350                let controlling_agency = required_component(unh, 1, 3)?.to_owned();
351                let association_code = unh
352                    .get_element(1)
353                    .and_then(|e| e.get_component(4))
354                    .unwrap_or("")
355                    .to_owned();
356
357                let declared_segment_count: u32 =
358                    required_component(seg, 0, 0)?.parse().map_err(|_| {
359                        EdifactError::InvalidText {
360                            offset: seg.span.start,
361                        }
362                    })?;
363                let unt_ref = required_component(seg, 1, 0)?;
364                if unt_ref != message_ref {
365                    return Err(EdifactError::QualifierMismatch {
366                        tag: "UNT".to_owned(),
367                        actual: unt_ref.to_owned(),
368                        expected: message_ref.clone(),
369                        offset: seg.span.start,
370                    });
371                }
372
373                // actual count = segments from UNH (inclusive) to UNT (inclusive)
374                let actual_segment_count = u32::try_from(i - msg_start_idx + 1).map_err(|_| {
375                    EdifactError::InterchangeTooLarge {
376                        // INVARIANT: usize ≤ u64::MAX on all supported targets; unwrap_or is
377                        // unreachable but prevents a panic on hypothetical exotic platforms.
378                        count: u64::try_from(i - msg_start_idx + 1).unwrap_or(u64::MAX),
379                    }
380                })?;
381
382                in_message = false;
383                messages.push(MessageEnvelope {
384                    message_ref,
385                    message_type,
386                    version,
387                    release,
388                    controlling_agency,
389                    association_code,
390                    declared_segment_count,
391                    actual_segment_count,
392                });
393            }
394            "UNT" => {
395                return Err(EdifactError::InvalidSegmentForMessage {
396                    tag: "UNT".to_owned(),
397                    message_type: "ENVELOPE".to_owned(),
398                    offset: seg.span.start,
399                });
400            }
401            "UNB" | "UNZ" if in_message => {
402                return Err(EdifactError::InvalidSegmentForMessage {
403                    tag: seg.tag.to_owned(),
404                    message_type: "ENVELOPE".to_owned(),
405                    offset: seg.span.start,
406                });
407            }
408            _ if !in_message => {
409                return Err(EdifactError::InvalidSegmentForMessage {
410                    tag: seg.tag.to_owned(),
411                    message_type: "ENVELOPE".to_owned(),
412                    offset: seg.span.start,
413                });
414            }
415            _ => {}
416        }
417    }
418
419    if in_message {
420        return Err(EdifactError::MissingSegment {
421            tag: "UNT".to_owned(),
422            expected_position: "end of message group".to_owned(),
423        });
424    }
425
426    Ok(messages)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    /// Parse test fixtures into an owned-segment vec (no memory leaks).
434    fn parse(input: &[u8]) -> Vec<crate::OwnedSegment> {
435        crate::from_reader_collect(std::io::Cursor::new(input)).expect("parse failed")
436    }
437
438    /// Parse then validate: convenience wrapper for tests that only need the result.
439    fn parse_and_validate(
440        input: &[u8],
441    ) -> Result<(InterchangeEnvelope, Vec<MessageEnvelope>), EdifactError> {
442        let owned = parse(input);
443        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
444        validate_envelope(&segs)
445    }
446
447    const VALID_INTERCHANGE: &[u8] =
448        b"UNA:+.? 'UNB+UNOA:3+SENDER::293+RECEIVER::293+230401:0900+00001'UNH+00001+ORDERS:D:11A:UN:EAN010'BGM+220+PO-4711+9'DTM+137:20230401:102'UNT+4+00001'UNZ+1+00001'";
449
450    #[test]
451    fn valid_envelope_parses_ok() {
452        let (interchange, messages) =
453            parse_and_validate(VALID_INTERCHANGE).expect("envelope should be valid");
454        assert_eq!(interchange.sender_id, "SENDER");
455        assert_eq!(interchange.recipient_id, "RECEIVER");
456        assert_eq!(interchange.control_ref, "00001");
457        assert_eq!(interchange.declared_message_count, 1);
458        assert_eq!(interchange.actual_message_count, 1);
459        assert_eq!(messages.len(), 1);
460        assert_eq!(messages[0].message_type, "ORDERS");
461        assert_eq!(messages[0].association_code, "EAN010");
462        assert_eq!(messages[0].declared_segment_count, 4);
463        assert_eq!(messages[0].actual_segment_count, 4); // UNH + BGM + DTM + UNT
464    }
465
466    #[test]
467    fn unt_count_mismatch_returns_err() {
468        // UNT declares 99 segments but only 4 are present
469        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'DTM+137:20200101:102'UNT+99+1'UNZ+1+1'";
470        let result = parse_and_validate(input);
471        assert!(
472            matches!(
473                result,
474                Err(EdifactError::SegmentCountMismatch { expected: 99, .. })
475            ),
476            "expected SegmentCountMismatch, got {result:?}"
477        );
478    }
479
480    #[test]
481    fn unz_count_mismatch_returns_err() {
482        // UNZ declares 2 messages but only 1 UNH/UNT pair is present
483        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+2+1'";
484        let result = parse_and_validate(input);
485        assert!(
486            matches!(
487                result,
488                Err(EdifactError::MessageCountMismatch {
489                    expected: 2,
490                    actual: 1
491                })
492            ),
493            "expected MessageCountMismatch(2,1), got {result:?}"
494        );
495    }
496
497    #[test]
498    fn missing_unb_returns_err() {
499        let input = b"UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
500        let result = parse_and_validate(input);
501        assert!(result.is_err());
502    }
503
504    #[test]
505    fn extracts_una_interchange_correctly() {
506        // Test that UNA does not interfere with envelope field extraction
507        let (env, _) = parse_and_validate(VALID_INTERCHANGE).unwrap();
508        // UNA is parsed by tokenizer; UNB field extraction must be correct
509        assert_eq!(env.syntax_identifier, "UNOA");
510        assert_eq!(env.datetime, "230401:0900");
511    }
512
513    #[test]
514    fn dangling_unh_without_unt_returns_err() {
515        let input =
516            b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNZ+1+1'";
517        let result = parse_and_validate(input);
518        assert!(
519            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNT")
520        );
521    }
522
523    #[test]
524    fn stray_segment_outside_message_returns_err() {
525        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'BGM+999+PO-2+9'UNZ+1+1'";
526        let result = parse_and_validate(input);
527        assert!(matches!(
528            result,
529            Err(EdifactError::InvalidSegmentForMessage { .. })
530        ));
531    }
532
533    #[test]
534    fn missing_unb_sender_component_returns_err() {
535        let input = b"UNB+UNOA:3++R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
536        let result = parse_and_validate(input);
537        // Element 1 (sender) exists but is empty ("+") — component 0 is absent.
538        assert!(
539            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 0 }) if tag == "UNB"),
540            "expected MissingRequiredComponent for empty sender, got: {result:?}"
541        );
542    }
543
544    #[test]
545    fn nested_unh_without_closing_previous_message_returns_err() {
546        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNH+2+ORDERS:D:11A:UN:EAN010'UNT+3+2'UNZ+1+1'";
547        let result = parse_and_validate(input);
548        assert!(
549            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNH"),
550            "expected InvalidSegmentForMessage(UNH), got {result:?}"
551        );
552    }
553
554    #[test]
555    fn unt_message_reference_must_match_unh() {
556        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+999'UNZ+1+1'";
557        let result = parse_and_validate(input);
558        assert!(matches!(result, Err(EdifactError::QualifierMismatch { tag, .. }) if tag == "UNT"));
559    }
560
561    #[test]
562    fn unz_control_reference_must_match_unb() {
563        let input = b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+999'";
564        let result = parse_and_validate(input);
565        assert!(matches!(result, Err(EdifactError::QualifierMismatch { tag, .. }) if tag == "UNZ"));
566    }
567
568    #[test]
569    fn missing_unh_message_type_components_return_err() {
570        let input =
571            b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
572        let result = parse_and_validate(input);
573        // UNH element 1 = "ORDERS:D:11A" — component 3 (controlling agency) is absent.
574        assert!(
575            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 3 }) if tag == "UNH"),
576            "expected MissingRequiredComponent for truncated UNH message type, got: {result:?}"
577        );
578    }
579
580    #[test]
581    fn nested_unz_inside_message_returns_err() {
582        let input =
583            b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'UNZ+1+1'UNT+2+1'UNZ+1+1'";
584        let result = parse_and_validate(input);
585        assert!(
586            matches!(result, Err(EdifactError::InvalidSegmentForMessage { tag, .. }) if tag == "UNZ")
587        );
588    }
589
590    // ── UNG/UNE functional-group regression guard ────────────────────────────
591    //
592    // ISO 9735-1 defines optional functional groups (UNG/UNE) that may wrap
593    // one or more UNH/UNT pairs.  `validate_envelope` currently documents that
594    // UNG/UNE are NOT supported (see module doc at line ~62).  These tests
595    // assert the *documented* behaviour: UNG/UNE-wrapped interchanges must
596    // not silently produce incorrect counts — they must return an explicit error.
597
598    #[test]
599    fn envelope_with_ung_returns_explicit_error() {
600        // A UNG segment appearing between UNB and UNH is not a recognized
601        // envelope segment — validate_envelope must reject it explicitly.
602        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
603                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
604                      UNH+1+ORDERS:D:96A:UN'\
605                      BGM+220+PO-001+9'\
606                      UNT+3+1'\
607                      UNE+1+1'\
608                      UNZ+1+1'";
609        let result = parse_and_validate(input);
610        assert!(
611            result.is_err(),
612            "UNG/UNE is documented as unsupported; must return an error, not silently produce wrong counts"
613        );
614        // The error must be the dedicated FunctionalGroupNotSupported variant,
615        // not some unrelated internal failure.
616        assert!(
617            matches!(
618                result,
619                Err(EdifactError::FunctionalGroupNotSupported { .. })
620            ),
621            "expected FunctionalGroupNotSupported, got {result:?}"
622        );
623    }
624}