Skip to main content

edifact_rs/
envelope.rs

1//! EDIFACT envelope validation — UNB / UNG / UNH / UNT / UNE / UNZ.
2//!
3//! Validates the full ISO 9735-1 interchange structure including optional
4//! groups (`UNG`/`UNE`).  The public surface is:
5//!
6//! - [`validate_envelope`] / [`validate_envelope_from_owned`] — fail-fast strict validation
7//! - [`validate_envelope_lenient`] / [`validate_envelope_lenient_from_owned`] — collects all errors
8//! - [`parse_unh`] — zero-copy parse of UNH identifier fields
9//!
10//! # What is checked
11//!
12//! | Rule | Source |
13//! |---|---|
14//! | The interchange opens with `UNB` and closes with `UNZ` | §7.1 |
15//! | It contains at least one message or group | §7.1 |
16//! | A group opens with `UNG`, closes with `UNE`, and is not nested | §7.2 |
17//! | A message opens with `UNH`, closes with `UNT`, and has a body | §7.3 |
18//! | `UNZ` DE 0020 repeats `UNB` DE 0020 | Annex C.1.5, UNZ note 1 |
19//! | `UNE` DE 0048 repeats `UNG` DE 0048 | Annex C.1.5, UNE note 1 |
20//! | `UNT` DE 0062 repeats `UNH` DE 0062 | Annex C.1.5, UNT note 1 |
21//! | `UNH` DE 0062 and `UNG` DE 0048 are unique within the interchange | Annex C.1.5, notes 2 / 5 |
22//! | `UNB` S001 DE 0001 names a defined character repertoire | §6, Annex C.3.4 |
23//! | The declared counts match what is there | Annex C.3.4, DE 0036 / 0060 / 0074 |
24//!
25//! # Count semantics
26//!
27//! The three control counts are defined in Annex C.3.4 and each counts something
28//! different:
29//!
30//! - `UNZ` DE 0036 — "the number of messages and packages in an interchange or,
31//!   if used, the number of groups in an interchange".
32//! - `UNE` DE 0060 — "the number of messages and packages in the group".
33//! - `UNT` DE 0074 — "the number of segments in a message body, plus the message
34//!   header segment and message trailer segment".
35//!
36//! A discrepancy is reported as [`EdifactError::MessageCountMismatch`] or
37//! [`EdifactError::SegmentCountMismatch`].
38//!
39//! # Packages
40//!
41//! An interchange may carry packages (`UNO`…`UNP`) instead of, or alongside,
42//! messages (§7.9). The object inside a package is arbitrary binary data whose
43//! length is declared in `UNO` S022 DE 0810 — it is not EDIFACT-encoded and this
44//! crate does not tokenize it. A package therefore reaches this validator as
45//! [`EdifactError::PackageNotSupported`] rather than as a misleading complaint
46//! about a stray segment.
47
48use crate::{
49    OwnedSegment,
50    error::EdifactError,
51    model::{Segment, Span},
52};
53use std::collections::HashSet;
54
55// ── Sealed segment-access trait ──────────────────────────────────────────────
56
57pub(crate) trait SegmentReader: sealed::Sealed {
58    fn tag(&self) -> &str;
59    fn span(&self) -> Span;
60    fn component(&self, elem_idx: usize, comp_idx: usize) -> Option<&str>;
61
62    fn required_component_field(
63        &self,
64        elem_idx: usize,
65        comp_idx: usize,
66    ) -> Result<&str, EdifactError> {
67        self.component(elem_idx, comp_idx)
68            .filter(|s| !s.is_empty())
69            .ok_or_else(|| EdifactError::MissingRequiredComponent {
70                tag: self.tag().to_owned(),
71                element_index: elem_idx,
72                component_index: comp_idx,
73            })
74    }
75}
76
77mod sealed {
78    pub trait Sealed {}
79    impl Sealed for crate::model::Segment<'_> {}
80    impl Sealed for crate::OwnedSegment {}
81}
82
83impl SegmentReader for Segment<'_> {
84    #[inline]
85    fn tag(&self) -> &str {
86        self.tag
87    }
88    #[inline]
89    fn span(&self) -> Span {
90        self.span
91    }
92    #[inline]
93    fn component(&self, elem_idx: usize, comp_idx: usize) -> Option<&str> {
94        self.get_element(elem_idx)?.get_component(comp_idx)
95    }
96}
97
98impl SegmentReader for OwnedSegment {
99    #[inline]
100    fn tag(&self) -> &str {
101        &self.tag
102    }
103    #[inline]
104    fn span(&self) -> Span {
105        self.span
106    }
107    #[inline]
108    fn component(&self, elem_idx: usize, comp_idx: usize) -> Option<&str> {
109        self.component_str(elem_idx, comp_idx)
110    }
111}
112
113// ── Public data types ─────────────────────────────────────────────────────────
114
115/// Extracted data from the `UNB` / `UNZ` interchange envelope.
116///
117/// All standard UNB fields that carry business-relevant information are
118/// exposed.  Optional fields that are absent in the source are represented
119/// as empty strings (`syntax_version`, qualifiers) or `None` (optional fields).
120///
121/// UNB element positions (ISO 9735-1 Annex C.1.5 (UNB), 0-indexed):
122///
123/// ```text
124/// [0] S001  syntax identifier + version
125/// [1] S002  sender id (0004) + qualifier (0007) + internal id (0008)
126/// [2] S003  recipient id (0010) + qualifier (0007) + internal id (0014)
127/// [3] S004  date + time
128/// [4] 0020  interchange control reference
129/// [5] S005  recipient password (DE 0022 comp 0)
130/// [6] 0026  application reference
131/// [7] 0029  processing priority code
132/// [8] 0031  acknowledgement request
133/// [9] 0032  communications agreement ID
134///[10] 0035  test indicator
135/// ```
136#[derive(Debug, Clone, PartialEq, Eq, Hash)]
137#[non_exhaustive]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139pub struct InterchangeEnvelope {
140    /// Syntax identifier, e.g. `"UNOA"` or `"UNOB"` (UNB S001 DE 0001).
141    pub syntax_identifier: String,
142    /// Syntax version number, e.g. `"3"` (UNB S001 DE 0002).
143    ///
144    /// Empty string when the UNB omits the version component.
145    pub syntax_version: String,
146    /// Interchange sender identification (UNB S002 DE 0004).
147    pub sender_id: String,
148    /// Interchange sender identification code qualifier (UNB S002 DE 0007).
149    ///
150    /// Common values: `"14"` (EAN/GLN), `"ZZZ"` (mutually defined).
151    /// Empty string when no qualifier is present.
152    pub sender_qualifier: String,
153    /// Interchange sender internal identification (UNB S002 **DE 0008**), if present.
154    ///
155    /// An optional address used by some EDI networks to identify the sub-entity
156    /// (division, application) within the sender organisation; ISO 9735 version 3
157    /// calls it the address for reverse routing.
158    ///
159    /// This is **DE 0008**, not DE 0014 — 0014 is the recipient-side component in
160    /// S003.  See [`service::S002`][crate::service::S002].
161    pub sender_routing_address: Option<String>,
162    /// Interchange recipient identification (UNB S003 DE 0010).
163    pub recipient_id: String,
164    /// Interchange recipient identification code qualifier (UNB S003 DE 0007).
165    ///
166    /// Same values as `sender_qualifier`.  Empty string when absent.
167    pub recipient_qualifier: String,
168    /// Interchange recipient internal identification (UNB S003 DE 0014), if present.
169    ///
170    /// The recipient-side counterpart of `sender_routing_address`, which is
171    /// DE 0008.  See [`service::S003`][crate::service::S003].
172    pub recipient_routing_address: Option<String>,
173    /// Interchange date (UNB S004 DE 0017), e.g. `"230401"` (YYMMDD format).
174    pub date: String,
175    /// Interchange time (UNB S004 DE 0019), e.g. `"0900"` (HHMM format), if present.
176    ///
177    /// `None` when the UNB time component (DE 0019) is absent.
178    pub time: Option<String>,
179    /// Interchange control reference (UNB DE 0020).
180    pub control_ref: String,
181    /// Recipient's reference/password (UNB S005 DE 0022), if present.
182    ///
183    /// Used in some EDI networks for basic interchange-level authentication.
184    /// Empty S005 in the source yields `None`.
185    pub recipient_password: Option<String>,
186    /// Recipient's reference/password qualifier (UNB S005 DE 0025), if present.
187    ///
188    /// Qualifies the type of the `recipient_password`.  Example value: `"AA"` (unencoded).
189    /// `None` when DE 0025 is absent or empty.
190    pub recipient_password_qualifier: Option<String>,
191    /// Application reference (UNB DE 0026, element index 6), if present.
192    ///
193    /// Identifies the division, department, or section of sender or recipient.
194    pub app_ref: Option<String>,
195    /// Processing priority code (UNB DE 0029, element index 7), if present.
196    ///
197    /// Indicates the processing priority requested by the sender.
198    /// Rarely used in practice; included here for full ISO 9735-1 Annex C.1.5 (UNB) compliance.
199    pub processing_priority: Option<String>,
200    /// Acknowledgement request flag (UNB DE 0031, element index 8).
201    ///
202    /// `true` when DE 0031 is `"1"`, indicating that the sender requests a
203    /// `CONTRL` functional acknowledgement from the recipient.
204    pub acknowledgement_request: bool,
205    /// Communications agreement identifier (UNB DE 0032, element index 9), if present.
206    ///
207    /// Identifies the agreement controlling the interchange.
208    pub communications_agreement_id: Option<String>,
209    /// Test indicator flag (UNB DE 0035, element index 10).
210    ///
211    /// `true` when DE 0035 is `"1"`.  Test interchanges **must not** be processed
212    /// as production data — check [`is_test()`](Self::is_test) before dispatching
213    /// messages to business logic, billing, or downstream integrations.
214    pub test_indicator: bool,
215    /// Interchange unit count declared in `UNZ` DE 0036.
216    ///
217    /// - When no functional groups are present: count of messages (`UNH`/`UNT` pairs).
218    /// - When functional groups are present: count of groups (`UNG`/`UNE` pairs).
219    ///
220    /// Use [`ValidatedInterchange::messages`] for a flat count of all messages
221    /// regardless of group structure.
222    pub declared_unit_count: u32,
223    /// Actual unit count observed (groups if groups present; messages otherwise).
224    pub actual_unit_count: u32,
225}
226
227impl InterchangeEnvelope {
228    /// Returns `true` when the test indicator (`UNB` DE 0035) is set to `"1"`.
229    ///
230    /// Production systems must check this flag before dispatching any message
231    /// to business logic, billing, or downstream integrations.
232    ///
233    /// # Example
234    ///
235    /// ```
236    /// // UNB element [10] is the test indicator; "1" means test.
237    /// // UNB+UNOA:3+S+R+200101:0900+1++++++1'  ← last element = "1" → is_test() == true
238    /// let input = b"UNB+UNOA:3+S+R+200101:0900+CTRL++++++1'\
239    ///               UNH+1+ORDERS:D:96A:UN'\
240    ///               BGM+220+PO-001+9'\
241    ///               UNT+3+1'\
242    ///               UNZ+1+CTRL'";
243    /// let segs: Vec<_> = edifact_rs::from_bytes(input)
244    ///     .collect::<Result<Vec<_>, _>>()
245    ///     .unwrap();
246    /// let result = edifact_rs::validate_envelope(&segs).unwrap();
247    /// assert!(result.interchange.is_test());
248    /// ```
249    #[inline]
250    #[must_use]
251    pub fn is_test(&self) -> bool {
252        self.test_indicator
253    }
254
255    /// Returns `true` when the acknowledgement request flag (UNB DE 0031) is set.
256    ///
257    /// When `true`, the sender expects a `CONTRL` acknowledgement from the recipient.
258    #[inline]
259    #[must_use]
260    pub fn ack_requested(&self) -> bool {
261        self.acknowledgement_request
262    }
263}
264
265impl std::fmt::Display for InterchangeEnvelope {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        write!(
268            f,
269            "{sender} -> {recipient} [{ctrl}] ({syntax}:{ver})",
270            sender = self.sender_id,
271            recipient = self.recipient_id,
272            ctrl = self.control_ref,
273            syntax = self.syntax_identifier,
274            ver = self.syntax_version,
275        )
276    }
277}
278
279/// Extracted data from a single `UNH` / `UNT` message envelope.
280#[derive(Debug, Clone, PartialEq, Eq, Hash)]
281#[non_exhaustive]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283pub struct MessageEnvelope {
284    /// Message reference from `UNH` element 0.
285    pub message_ref: String,
286    /// EDIFACT message type, e.g. `"ORDERS"`.
287    pub message_type: String,
288    /// Version number, e.g. `"D"`.
289    pub version: String,
290    /// Release number, e.g. `"11A"`.
291    pub release: String,
292    /// Controlling agency code, e.g. `"UN"`.
293    pub controlling_agency: String,
294    /// Association assigned code (MIG version), e.g. `"FV2510"`.
295    pub association_code: String,
296    /// Common access reference (UNH DE 0068, element index 2), if present.
297    ///
298    /// A reference shared across related messages or exchanges on the same network
299    /// path.  Used by some EDI network profiles to
300    /// correlate messages that belong to a single business transaction.
301    /// `None` when element \[2\] is absent or empty.
302    pub common_access_ref: Option<String>,
303    /// Sequence of transfers (UNH S010 DE 0070, element index 3), if present.
304    ///
305    /// When a large message is split across multiple interchanges, this is the
306    /// 1-based index of this segment within the sequence.  `None` when the message
307    /// is not split (element \[3\] absent).
308    pub sequence_of_transfers: Option<u32>,
309    /// Transfer position indicator (UNH S010 DE 0073, element index 3 comp 1), if present.
310    ///
311    /// Values per ISO 9735-1 Annex C.3.4 (DE 0073): `"C"` = continuation, `"F"` = first, `"L"` = last.
312    /// `None` when element \[3\] is absent.
313    pub transfer_position: Option<String>,
314    /// Declared segment count from `UNT`.
315    pub declared_segment_count: u32,
316    /// Actual segment count between this `UNH` and its `UNT`.
317    pub actual_segment_count: u32,
318    /// Byte range of this message's `UNH`.
319    pub header_span: Span,
320    /// Byte range of this message's `UNT`.
321    ///
322    /// A count mismatch is the trailer's fault, so this is where a diagnostic
323    /// should point and what places the finding on this message when a
324    /// [`Contrl`][crate::Contrl] is built from the report.
325    pub trailer_span: Span,
326}
327
328impl std::fmt::Display for MessageEnvelope {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        write!(
331            f,
332            "{msg_type}:{ver}:{rel} ref={msg_ref} seg={actual}/{declared}",
333            msg_type = self.message_type,
334            ver = self.version,
335            rel = self.release,
336            msg_ref = self.message_ref,
337            actual = self.actual_segment_count,
338            declared = self.declared_segment_count,
339        )
340    }
341}
342
343/// Extracted data from a single `UNG` / `UNE` functional group envelope.
344///
345/// ISO 9735-1 §7.2 defines optional functional groups that may wrap one or more
346/// `UNH`/`UNT` message pairs.  This type carries the parsed fields from both
347/// the `UNG` header and its matching `UNE` trailer, plus the validated messages.
348#[derive(Debug, Clone, PartialEq, Eq, Hash)]
349#[non_exhaustive]
350#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
351pub struct FunctionalGroupEnvelope {
352    /// Functional group identification (UNG DE 0038), e.g. `"ORDERS"`.
353    pub group_id: String,
354    /// Application sender's identification (UNG S006 DE 0040).
355    pub app_sender: String,
356    /// Application sender identification code qualifier (UNG S006 DE 0007).
357    ///
358    /// Empty string when no qualifier is present.
359    pub app_sender_qualifier: String,
360    /// Application recipient's identification (UNG S007 DE 0044).
361    pub app_recipient: String,
362    /// Application recipient identification code qualifier (UNG S007 DE 0007).
363    ///
364    /// Empty string when no qualifier is present.
365    pub app_recipient_qualifier: String,
366    /// Date of preparation (UNG S004 DE 0017), e.g. `"200101"` (YYMMDD format).
367    pub date: String,
368    /// Time of preparation (UNG S004 DE 0019), e.g. `"0900"` (HHMM format), if present.
369    pub time: Option<String>,
370    /// Functional group reference number (UNG DE 0048). Must match `UNE` DE 0048.
371    pub group_ref: String,
372    /// Controlling agency, coded (UNG DE 0051), e.g. `"UN"`.
373    pub controlling_agency: String,
374    /// Message version number, e.g. `"D"`.
375    pub version: String,
376    /// Message release number, e.g. `"96A"`.
377    pub release: String,
378    /// Application password (UNG DE 0058), if present.
379    ///
380    /// A password to the recipient's division, department, or sectional
381    /// application system — the group-level counterpart of the interchange's
382    /// `recipient_password`.
383    pub application_password: Option<String>,
384    /// Declared message count from `UNE` DE 0060.
385    pub declared_message_count: u32,
386    /// Actual number of `UNH`/`UNT` pairs found within this group.
387    pub actual_message_count: u32,
388    /// Messages contained within this functional group.
389    pub messages: Vec<MessageEnvelope>,
390}
391
392/// Fully validated interchange structure returned by [`validate_envelope`].
393///
394/// Provides both hierarchical (group → message) and flat (all messages) access
395/// so that callers who do not care about group boundaries can use
396/// [`messages`](Self::messages) directly.
397#[derive(Debug, Clone, PartialEq, Eq, Hash)]
398#[non_exhaustive]
399#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
400pub struct ValidatedInterchange {
401    /// Interchange-level envelope data (from `UNB`/`UNZ`).
402    pub interchange: InterchangeEnvelope,
403    /// Functional groups, when the interchange uses `UNG`/`UNE` wrappers.
404    ///
405    /// Empty when messages appear directly under the interchange (the common
406    /// case for most modern EDIFACT implementations).
407    pub functional_groups: Vec<FunctionalGroupEnvelope>,
408    /// Flat list of all messages in the interchange.
409    ///
410    /// When functional groups are present this contains the same messages as
411    /// the nested `messages` fields inside each [`FunctionalGroupEnvelope`].
412    pub messages: Vec<MessageEnvelope>,
413}
414
415impl ValidatedInterchange {
416    /// Returns `true` if this interchange uses `UNG`/`UNE` functional group wrappers.
417    #[inline]
418    #[must_use]
419    pub fn has_functional_groups(&self) -> bool {
420        !self.functional_groups.is_empty()
421    }
422
423    /// Total number of `UNH`/`UNT` message pairs across all groups.
424    ///
425    /// Equivalent to `self.messages.len()` but communicates intent clearly.
426    #[inline]
427    #[must_use]
428    pub fn message_count(&self) -> usize {
429        self.messages.len()
430    }
431
432    /// Iterate over all messages in the interchange.
433    ///
434    /// Equivalent to `self.messages.iter()` but communicates intent clearly
435    /// and is stable regardless of future internal layout changes.
436    #[inline]
437    pub fn iter_messages(&self) -> impl Iterator<Item = &MessageEnvelope> {
438        self.messages.iter()
439    }
440
441    /// Find the first message whose `message_ref` equals `reference`.
442    ///
443    /// Useful for locating a specific message in an interchange with multiple
444    /// messages after calling `validate_envelope`.
445    ///
446    /// Returns `None` if no message with that reference exists.
447    #[inline]
448    #[must_use]
449    pub fn find_message(&self, reference: &str) -> Option<&MessageEnvelope> {
450        self.messages.iter().find(|m| m.message_ref == reference)
451    }
452
453    /// Collect all messages of a given type (e.g. `"ORDERS"`, `"INVOIC"`).
454    ///
455    /// Returns a `Vec` of references to matching messages in document order.
456    /// Returns an empty `Vec` when the interchange contains no messages of
457    /// the requested type.
458    ///
459    /// Prefer [`iter_messages_by_type`](Self::iter_messages_by_type) in tight loops
460    /// to avoid the allocation.
461    #[must_use]
462    pub fn messages_by_type(&self, message_type: &str) -> Vec<&MessageEnvelope> {
463        self.messages
464            .iter()
465            .filter(|m| m.message_type == message_type)
466            .collect()
467    }
468
469    /// Iterate over all messages of a given type without allocating.
470    ///
471    /// Zero-allocation alternative to [`messages_by_type`](Self::messages_by_type).
472    ///
473    /// The bound `'q: 's` means the `message_type` string reference must outlive the
474    /// borrow of `self`.  In practice this is always satisfied when passing a string
475    /// literal (`&'static str`) or any string whose lifetime is at least as long as
476    /// the `ValidatedInterchange` reference.  For short-lived computed strings, use
477    /// [`messages_by_type`](Self::messages_by_type) which collects eagerly and releases the string reference
478    /// immediately.
479    #[inline]
480    pub fn iter_messages_by_type<'s, 'q: 's>(
481        &'s self,
482        message_type: &'q str,
483    ) -> impl Iterator<Item = &'s MessageEnvelope> + 's {
484        self.messages
485            .iter()
486            .filter(move |m| m.message_type == message_type)
487    }
488}
489
490impl std::fmt::Display for ValidatedInterchange {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        write!(
493            f,
494            "{ic} messages={n}",
495            ic = self.interchange,
496            n = self.messages.len(),
497        )
498    }
499}
500
501impl std::fmt::Display for FunctionalGroupEnvelope {
502    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        write!(
504            f,
505            "{gid} sender={sender} recipient={recip} [{gref}] ({agency}) msgs={actual}/{declared}",
506            gid = self.group_id,
507            sender = self.app_sender,
508            recip = self.app_recipient,
509            gref = self.group_ref,
510            agency = self.controlling_agency,
511            actual = self.actual_message_count,
512            declared = self.declared_message_count,
513        )
514    }
515}
516
517/// Parsed identifier fields from a `UNH` segment.
518///
519/// All string slices borrow from the input bytes so they live as long as the
520/// original byte buffer.
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
522#[non_exhaustive]
523#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
524pub struct MessageIdentifier<'a> {
525    /// Message reference number (UNH DE 0062, element index 0).
526    pub message_ref: &'a str,
527    pub message_type: &'a str,
528    pub version: &'a str,
529    pub release: &'a str,
530    pub controlling_agency: &'a str,
531    /// Association assigned code (UNH S009 DE 0057).
532    ///
533    /// Matches `MessageEnvelope::association_code` for the same message.
534    pub association_code: &'a str,
535}
536
537// ── Public API ────────────────────────────────────────────────────────────────
538
539/// Extract identifier fields from a `UNH` segment (zero allocation).
540pub fn parse_unh<'a>(unh: &'a Segment<'a>) -> Result<MessageIdentifier<'a>, EdifactError> {
541    // Element [0]: DE 0062 — message reference number (required, simple DE)
542    let message_ref = unh
543        .get_element(0)
544        .and_then(|e| e.get_component(0))
545        .filter(|s| !s.is_empty())
546        .ok_or_else(|| EdifactError::MissingRequiredComponent {
547            tag: "UNH".to_owned(),
548            element_index: 0,
549            component_index: 0,
550        })?;
551    // Element [1]: S009 composite — message type, version, release, agency, association
552    let elem = unh
553        .get_element(1)
554        .ok_or_else(|| EdifactError::MissingRequiredElement {
555            tag: "UNH".to_owned(),
556            element_index: 1,
557        })?;
558    let message_type =
559        elem.get_component(0)
560            .ok_or_else(|| EdifactError::MissingRequiredComponent {
561                tag: "UNH".to_owned(),
562                element_index: 1,
563                component_index: 0,
564            })?;
565    Ok(MessageIdentifier {
566        message_ref,
567        message_type,
568        version: elem.get_component(1).unwrap_or(""),
569        release: elem.get_component(2).unwrap_or(""),
570        controlling_agency: elem.get_component(3).unwrap_or(""),
571        association_code: elem.get_component(4).unwrap_or(""),
572    })
573}
574
575/// Parsed identifier fields from a `UNG` segment.
576///
577/// All string slices borrow from the input bytes so they live as long as the
578/// original byte buffer.  Use this for zero-allocation group routing in streaming
579/// scenarios where you need to inspect group identity without full validation.
580///
581/// # UNG element positions (ISO 9735-1 Annex C.1.5, 0-indexed)
582///
583/// ```text
584/// [0] DE 0038  message group identification
585/// [1] S006     application sender id + qualifier (comp 0 / comp 1)
586/// [2] S007     application recipient id + qualifier (comp 0 / comp 1)
587/// [3] S004     date + time (comp 0 / comp 1)
588/// [4] DE 0048  group reference number     ← the only mandatory one
589/// [5] DE 0051  controlling agency
590/// [6] S008     version + release (comp 0 / comp 1)
591/// [7] DE 0058  application password
592/// ```
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
594#[non_exhaustive]
595#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
596pub struct GroupIdentifier<'a> {
597    /// Functional group identification (UNG DE 0038), e.g. `"ORDERS"`.
598    pub group_id: &'a str,
599    /// Application sender identification (UNG S006 DE 0040).
600    pub app_sender: &'a str,
601    /// Application sender identification code qualifier (UNG S006 DE 0007).
602    pub app_sender_qualifier: &'a str,
603    /// Application recipient identification (UNG S007 DE 0044).
604    pub app_recipient: &'a str,
605    /// Application recipient identification code qualifier (UNG S007 DE 0007).
606    pub app_recipient_qualifier: &'a str,
607    /// Group reference number (UNG DE 0048).
608    pub group_ref: &'a str,
609    /// Controlling agency (UNG DE 0051), e.g. `"UN"`.
610    pub controlling_agency: &'a str,
611    /// Message version number from S008 (UNG DE 0052), e.g. `"D"`.
612    pub version: &'a str,
613    /// Message release number from S008 (UNG DE 0054), e.g. `"96A"`.
614    pub release: &'a str,
615    /// Application password (UNG DE 0058); empty when absent.
616    pub application_password: &'a str,
617}
618
619/// Extract identifier fields from a `UNG` segment (zero allocation).
620///
621/// The symmetric counterpart to [`parse_unh`] for streaming scenarios that need
622/// to inspect or route functional groups before full validation.
623pub fn parse_ung<'a>(ung: &'a Segment<'a>) -> Result<GroupIdentifier<'a>, EdifactError> {
624    let group_id = ung
625        .get_element(0)
626        .and_then(|e| e.get_component(0))
627        .unwrap_or("");
628    let app_sender = ung
629        .get_element(1)
630        .and_then(|e| e.get_component(0))
631        .unwrap_or("");
632    let app_sender_qualifier = ung
633        .get_element(1)
634        .and_then(|e| e.get_component(1))
635        .unwrap_or("");
636    let app_recipient = ung
637        .get_element(2)
638        .and_then(|e| e.get_component(0))
639        .unwrap_or("");
640    let app_recipient_qualifier = ung
641        .get_element(2)
642        .and_then(|e| e.get_component(1))
643        .unwrap_or("");
644    let group_ref = ung
645        .get_element(4)
646        .and_then(|e| e.get_component(0))
647        .ok_or_else(|| EdifactError::MissingRequiredComponent {
648            tag: "UNG".to_owned(),
649            element_index: 4,
650            component_index: 0,
651        })?;
652    let controlling_agency = ung
653        .get_element(5)
654        .and_then(|e| e.get_component(0))
655        .unwrap_or("");
656    let s008 = ung.get_element(6);
657    let version = s008.as_ref().and_then(|e| e.get_component(0)).unwrap_or("");
658    let release = s008.as_ref().and_then(|e| e.get_component(1)).unwrap_or("");
659    let application_password = ung
660        .get_element(7)
661        .and_then(|e| e.get_component(0))
662        .unwrap_or("");
663    Ok(GroupIdentifier {
664        group_id,
665        app_sender,
666        app_sender_qualifier,
667        app_recipient,
668        app_recipient_qualifier,
669        group_ref,
670        controlling_agency,
671        version,
672        release,
673        application_password,
674    })
675}
676
677/// Validate the EDIFACT interchange envelope (fail-fast, borrowed-segment path).
678///
679/// Supports direct-message interchanges and functional-group interchanges
680/// (ISO 9735-1 §7.2).  Returns [`ValidatedInterchange`] on success.
681pub fn validate_envelope(segments: &[Segment<'_>]) -> Result<ValidatedInterchange, EdifactError> {
682    validate_envelope_impl(segments)
683}
684
685/// Validate the EDIFACT interchange envelope (fail-fast, owned-segment path).
686pub fn validate_envelope_from_owned(
687    segments: &[OwnedSegment],
688) -> Result<ValidatedInterchange, EdifactError> {
689    validate_envelope_impl(segments)
690}
691
692/// Result of a lenient envelope validation — carries both a (possibly partial)
693/// interchange and the full list of collected errors.
694///
695/// Returned by [`validate_envelope_lenient`] and [`validate_envelope_lenient_from_owned`].
696///
697/// # Semantics
698///
699/// | Condition | `interchange` | `errors` |
700/// |-----------|---------------|----------|
701/// | Structurally valid, all counts correct | `Some(result)` | empty |
702/// | Structurally parseable but count violations | `Some(partial)` | non-empty |
703/// | Missing `UNB`/`UNZ`, stray segments, etc. | `None` | non-empty |
704#[derive(Debug)]
705#[non_exhaustive]
706pub struct LenientResult {
707    /// The parsed interchange, if extraction was structurally possible.
708    pub interchange: Option<ValidatedInterchange>,
709    /// All errors collected during validation, in discovery order.
710    pub errors: Vec<EdifactError>,
711}
712
713impl LenientResult {
714    /// Returns `true` if no errors were detected and the interchange is fully valid.
715    #[inline]
716    #[must_use]
717    pub fn is_valid(&self) -> bool {
718        self.errors.is_empty()
719    }
720
721    /// Returns `true` if one or more errors were collected.
722    ///
723    /// The readable inverse of [`is_valid`](Self::is_valid).
724    /// A partial interchange may still be present even when `has_errors()` returns `true`.
725    #[inline]
726    #[must_use]
727    pub fn has_errors(&self) -> bool {
728        !self.errors.is_empty()
729    }
730
731    /// Convert into a `Result`, returning the interchange on success or the errors on failure.
732    ///
733    /// The partial interchange (when present alongside errors) is discarded on the
734    /// `Err` path.  Use the fields directly when you need both simultaneously.
735    ///
736    /// This conversion is total.  Because [`errors`](Self::errors) is a public
737    /// field, callers may legitimately filter out violations they tolerate before
738    /// converting; if that leaves no errors but also no interchange, the result is
739    /// an empty `Err` rather than a panic.
740    pub fn into_strict(self) -> Result<ValidatedInterchange, Vec<EdifactError>> {
741        match self.interchange {
742            Some(interchange) if self.errors.is_empty() => Ok(interchange),
743            _ => Err(self.errors),
744        }
745    }
746}
747
748/// Validate the EDIFACT envelope and collect **all** errors rather than stopping
749/// at the first failure (borrowed-segment path).
750///
751/// Returns a [`LenientResult`] whose `interchange` field is:
752///
753/// - `Some(result)` with empty `errors` when fully valid.
754/// - `Some(partial)` with non-empty `errors` when only count violations were found —
755///   lets diagnostic tooling display the actual interchange structure.
756/// - `None` when the interchange is structurally broken beyond recovery
757///   (missing `UNB`/`UNZ`, stray segments, etc.).
758pub fn validate_envelope_lenient(segments: &[Segment<'_>]) -> LenientResult {
759    validate_envelope_lenient_impl(segments)
760}
761
762/// Lenient validation over an owned-segment slice — collects all errors.
763///
764/// See [`validate_envelope_lenient`] for full semantics.
765pub fn validate_envelope_lenient_from_owned(segments: &[OwnedSegment]) -> LenientResult {
766    validate_envelope_lenient_impl(segments)
767}
768
769// ── Core implementation ───────────────────────────────────────────────────────
770
771/// Collector for **recoverable** envelope violations.
772///
773/// Extraction distinguishes two error classes:
774///
775/// * *Recoverable* — the violation is recorded here and extraction substitutes a
776///   fallback value, so later checks still run.  Control-reference mismatches,
777///   missing mandatory components, and unparseable counts are all recoverable.
778/// * *Fatal* — the structure cannot be interpreted at all (no `UNB`/`UNZ`, a
779///   stray segment outside any message, an unterminated message).  These are
780///   still returned via `Err` and abort extraction.
781///
782/// Strict and lenient validation share one implementation over this sink, which
783/// is what keeps them from diverging: strict reports the first error the sink
784/// collected, lenient reports all of them.
785#[derive(Default)]
786struct ErrorSink {
787    errors: Vec<EdifactError>,
788}
789
790impl ErrorSink {
791    #[inline]
792    fn push(&mut self, error: EdifactError) {
793        self.errors.push(error);
794    }
795
796    /// Record a recoverable failure and continue with `fallback`.
797    #[inline]
798    fn recover<T>(&mut self, result: Result<T, EdifactError>, fallback: T) -> T {
799        match result {
800            Ok(value) => value,
801            Err(error) => {
802                self.errors.push(error);
803                fallback
804            }
805        }
806    }
807
808    /// Read a mandatory component, recording `MissingRequiredComponent` if absent.
809    #[inline]
810    fn required<S: SegmentReader>(&mut self, seg: &S, element: usize, component: usize) -> String {
811        self.recover(
812            seg.required_component_field(element, component)
813                .map(str::to_owned),
814            String::new(),
815        )
816    }
817}
818
819/// Shared extraction used by both the strict and the lenient entry points.
820///
821/// Returns the interchange when the structure was interpretable at all, plus
822/// every violation found in discovery order.
823fn validate_envelope_collecting<S: SegmentReader>(
824    segments: &[S],
825) -> (Option<ValidatedInterchange>, Vec<EdifactError>) {
826    let mut sink = ErrorSink::default();
827
828    let mut interchange_env = match extract_interchange(segments, &mut sink) {
829        Ok(env) => env,
830        Err(fatal) => {
831            sink.push(fatal);
832            return (None, sink.errors);
833        }
834    };
835
836    let inner = if segments.len() >= 2 {
837        &segments[1..segments.len() - 1]
838    } else {
839        &[]
840    };
841
842    let (functional_groups, messages) = match extract_content(inner, &mut sink) {
843        Ok(pair) => pair,
844        Err(fatal) => {
845            sink.push(fatal);
846            return (None, sink.errors);
847        }
848    };
849
850    // ISO 9735-1 §7.1: an interchange "shall contain at least one group, or one
851    // message or one package".  An empty one is a delivery that says nothing —
852    // and `UNZ+0` makes the counts agree, so nothing else would catch it.
853    if functional_groups.is_empty() && messages.is_empty() {
854        sink.push(EdifactError::EmptyInterchange {
855            control_ref: interchange_env.control_ref.clone(),
856        });
857    }
858
859    // `UNZ` DE 0036 counts "the number of messages and packages in an interchange
860    // or, if used, the number of groups in an interchange" (Annex C.3.4).
861    let actual_unit_count = if functional_groups.is_empty() {
862        messages.len()
863    } else {
864        functional_groups.len()
865    };
866    interchange_env.actual_unit_count = sink.recover(
867        u32::try_from(actual_unit_count).map_err(|_| EdifactError::InterchangeTooLarge {
868            count: actual_unit_count as u64,
869        }),
870        u32::MAX,
871    );
872
873    if interchange_env.declared_unit_count != interchange_env.actual_unit_count {
874        sink.push(EdifactError::MessageCountMismatch {
875            expected: interchange_env.declared_unit_count,
876            actual: interchange_env.actual_unit_count,
877        });
878    }
879
880    for msg in &messages {
881        if msg.declared_segment_count != msg.actual_segment_count {
882            sink.push(EdifactError::SegmentCountMismatch {
883                expected: msg.declared_segment_count,
884                actual: msg.actual_segment_count,
885                message_ref: msg.message_ref.clone(),
886                span: msg.trailer_span,
887            });
888        }
889    }
890
891    (
892        Some(ValidatedInterchange {
893            interchange: interchange_env,
894            functional_groups,
895            messages,
896        }),
897        sink.errors,
898    )
899}
900
901fn validate_envelope_impl<S: SegmentReader>(
902    segments: &[S],
903) -> Result<ValidatedInterchange, EdifactError> {
904    match validate_envelope_collecting(segments) {
905        (Some(result), errors) if errors.is_empty() => Ok(result),
906        (_, mut errors) => Err(errors
907            .drain(..)
908            .next()
909            .unwrap_or(EdifactError::MissingSegment {
910                tag: "UNB".to_owned(),
911                expected_position: "first segment of interchange".to_owned(),
912            })),
913    }
914}
915
916fn validate_envelope_lenient_impl<S: SegmentReader>(segments: &[S]) -> LenientResult {
917    let (interchange, errors) = validate_envelope_collecting(segments);
918    LenientResult {
919        interchange,
920        errors,
921    }
922}
923
924// ── Interchange extraction ────────────────────────────────────────────────────
925
926fn extract_interchange<S: SegmentReader>(
927    segments: &[S],
928    sink: &mut ErrorSink,
929) -> Result<InterchangeEnvelope, EdifactError> {
930    if segments.first().map(|s| s.tag()) != Some("UNB") {
931        return Err(EdifactError::MissingSegment {
932            tag: "UNB".to_owned(),
933            expected_position: "first segment of interchange".to_owned(),
934        });
935    }
936    if segments.last().map(|s| s.tag()) != Some("UNZ") {
937        return Err(EdifactError::MissingSegment {
938            tag: "UNZ".to_owned(),
939            expected_position: "last segment of interchange".to_owned(),
940        });
941    }
942
943    let unb = &segments[0];
944    let unz = &segments[segments.len() - 1];
945
946    let syntax_identifier = sink.required(unb, 0, 0);
947    let syntax_version = unb.component(0, 1).unwrap_or("").to_owned();
948
949    // DE 0001 names a character repertoire, so `Charset` is the one place that
950    // knows which values exist — and it distinguishes "not a syntax identifier"
951    // from "a real repertoire this crate cannot decode".  A second hand-kept list
952    // here is exactly how `UNOY` came to be rejected as unrecognised while
953    // `Charset` decoded it happily.
954    if !syntax_identifier.is_empty() {
955        if let Err(error) = crate::Charset::from_syntax_identifier(&syntax_identifier) {
956            sink.push(error);
957        }
958    }
959
960    let sender_id = sink.required(unb, 1, 0);
961    let sender_qualifier = unb.component(1, 1).unwrap_or("").to_owned();
962    // UNB S002 comp[2]: DE 0008 — sender internal identification
963    let sender_routing_address = unb
964        .component(1, 2)
965        .filter(|s| !s.is_empty())
966        .map(str::to_owned);
967
968    let recipient_id = sink.required(unb, 2, 0);
969    let recipient_qualifier = unb.component(2, 1).unwrap_or("").to_owned();
970    // UNB S003 comp[2]: DE 0014 — recipient internal identification
971    let recipient_routing_address = unb
972        .component(2, 2)
973        .filter(|s| !s.is_empty())
974        .map(str::to_owned);
975
976    let date = sink.required(unb, 3, 0);
977    let time_raw = unb.component(3, 1).unwrap_or("");
978    let time = if time_raw.is_empty() {
979        None
980    } else {
981        Some(time_raw.to_owned())
982    };
983
984    let control_ref = sink.required(unb, 4, 0);
985
986    // UNB element [5]: S005 — recipient's reference/password (DE 0022, comp 0) + qualifier (DE 0025, comp 1)
987    let recipient_password = unb
988        .component(5, 0)
989        .filter(|s| !s.is_empty())
990        .map(str::to_owned);
991    let recipient_password_qualifier = unb
992        .component(5, 1)
993        .filter(|s| !s.is_empty())
994        .map(str::to_owned);
995    // UNB element [6]: DE 0026 — application reference
996    let app_ref = unb
997        .component(6, 0)
998        .filter(|s| !s.is_empty())
999        .map(str::to_owned);
1000    // UNB element [7]: DE 0029 — processing priority code
1001    let processing_priority = unb
1002        .component(7, 0)
1003        .filter(|s| !s.is_empty())
1004        .map(str::to_owned);
1005    // UNB element [8]: DE 0031 — acknowledgement request ("1" = requested)
1006    let acknowledgement_request = unb.component(8, 0) == Some("1");
1007    // UNB element [9]: DE 0032 — communications agreement ID
1008    let communications_agreement_id = unb
1009        .component(9, 0)
1010        .filter(|s| !s.is_empty())
1011        .map(str::to_owned);
1012    // UNB element [10]: DE 0035 — test indicator ("1" = test)
1013    let test_indicator = unb.component(10, 0) == Some("1");
1014
1015    let unz_control_ref = sink.required(unz, 1, 0);
1016    if unz_control_ref != control_ref {
1017        sink.push(EdifactError::QualifierMismatch {
1018            tag: "UNZ".to_owned(),
1019            actual: unz_control_ref,
1020            expected: control_ref.clone(),
1021            span: unz.span(),
1022        });
1023    }
1024
1025    let declared_unit_count_raw = sink.required(unz, 0, 0);
1026    let declared_unit_count: u32 = sink.recover(
1027        declared_unit_count_raw
1028            .parse()
1029            .map_err(|_| EdifactError::InvalidText {
1030                offset: unz.span().start,
1031            }),
1032        0,
1033    );
1034
1035    Ok(InterchangeEnvelope {
1036        syntax_identifier,
1037        syntax_version,
1038        sender_id,
1039        sender_qualifier,
1040        sender_routing_address,
1041        recipient_id,
1042        recipient_qualifier,
1043        recipient_routing_address,
1044        date,
1045        time,
1046        control_ref,
1047        recipient_password,
1048        recipient_password_qualifier,
1049        app_ref,
1050        processing_priority,
1051        acknowledgement_request,
1052        communications_agreement_id,
1053        test_indicator,
1054        declared_unit_count,
1055        actual_unit_count: 0,
1056    })
1057}
1058
1059// ── Content extraction ────────────────────────────────────────────────────────
1060
1061fn extract_content<S: SegmentReader>(
1062    inner: &[S],
1063    sink: &mut ErrorSink,
1064) -> Result<(Vec<FunctionalGroupEnvelope>, Vec<MessageEnvelope>), EdifactError> {
1065    // A UNG as the first inner segment means the interchange uses functional groups.
1066    // Checking only the first tag is O(1) and correct: if UNG is present it must
1067    // always be first; a stray UNE without a preceding UNG is caught downstream.
1068    if inner.first().is_some_and(|s| s.tag() == "UNG") {
1069        let groups = extract_with_groups(inner, sink)?;
1070        let messages = groups
1071            .iter()
1072            .flat_map(|g| g.messages.iter().cloned())
1073            .collect();
1074        Ok((groups, messages))
1075    } else {
1076        let mut seen_refs = HashSet::new();
1077        let messages = extract_messages_flat(inner, sink, &mut seen_refs)?;
1078        Ok((vec![], messages))
1079    }
1080}
1081
1082/// Find the index of the `UNE` that closes the `UNG` opened just before `start`.
1083fn find_matching_une<S: SegmentReader>(
1084    segments: &[S],
1085    start: usize,
1086) -> Result<usize, EdifactError> {
1087    for (offset, seg) in segments[start..].iter().enumerate() {
1088        match seg.tag() {
1089            "UNE" => return Ok(start + offset),
1090            "UNG" => {
1091                return Err(EdifactError::InvalidSegmentForMessage {
1092                    tag: "UNG".to_owned(),
1093                    message_type: "ENVELOPE".to_owned(),
1094                    span: seg.span(),
1095                });
1096            }
1097            _ => {}
1098        }
1099    }
1100    Err(EdifactError::MissingSegment {
1101        tag: "UNE".to_owned(),
1102        expected_position: "end of functional group".to_owned(),
1103    })
1104}
1105
1106fn extract_with_groups<S: SegmentReader>(
1107    inner: &[S],
1108    sink: &mut ErrorSink,
1109) -> Result<Vec<FunctionalGroupEnvelope>, EdifactError> {
1110    let mut groups: Vec<FunctionalGroupEnvelope> = Vec::new();
1111    // DE 0048 must be unique within the interchange (ISO 9735-1 §7.2); DE 0062
1112    // must be unique across the whole interchange, so the set spans all groups.
1113    let mut seen_group_refs: HashSet<String> = HashSet::new();
1114    let mut seen_message_refs: HashSet<String> = HashSet::new();
1115    let mut i = 0;
1116
1117    while i < inner.len() {
1118        let seg = &inner[i];
1119        match seg.tag() {
1120            "UNG" => {
1121                let ung_idx = i;
1122                let une_idx = find_matching_une(inner, ung_idx + 1)?;
1123
1124                let ung = &inner[ung_idx];
1125                let group_id = ung.component(0, 0).unwrap_or("").to_owned();
1126                let app_sender = ung.component(1, 0).unwrap_or("").to_owned();
1127                let app_sender_qualifier = ung.component(1, 1).unwrap_or("").to_owned();
1128                let app_recipient = ung.component(2, 0).unwrap_or("").to_owned();
1129                let app_recipient_qualifier = ung.component(2, 1).unwrap_or("").to_owned();
1130                let date = ung.component(3, 0).unwrap_or("").to_owned();
1131                let time_raw = ung.component(3, 1).unwrap_or("");
1132                let time = if time_raw.is_empty() {
1133                    None
1134                } else {
1135                    Some(time_raw.to_owned())
1136                };
1137                // UNG DE 0048 — group reference number (mandatory per ISO 9735-1 §7.2)
1138                let group_ref = sink.required(ung, 4, 0);
1139                if !seen_group_refs.insert(group_ref.clone()) {
1140                    sink.push(EdifactError::DuplicateReference {
1141                        tag: "UNG".to_owned(),
1142                        reference: group_ref.clone(),
1143                        span: ung.span(),
1144                    });
1145                }
1146                let controlling_agency = ung.component(5, 0).unwrap_or("").to_owned();
1147                // UNG S008 — version (DE 0052, comp 0) + release (DE 0054, comp 1).
1148                let version = ung.component(6, 0).unwrap_or("").to_owned();
1149                let release = ung.component(6, 1).unwrap_or("").to_owned();
1150                // UNG DE 0058 — application password (Annex C.1.5, position 080).
1151                let application_password = ung
1152                    .component(7, 0)
1153                    .filter(|s| !s.is_empty())
1154                    .map(str::to_owned);
1155
1156                let une = &inner[une_idx];
1157                let declared_str = sink.required(une, 0, 0);
1158                let declared_message_count: u32 = sink.recover(
1159                    declared_str.parse().map_err(|_| EdifactError::InvalidText {
1160                        offset: une.span().start,
1161                    }),
1162                    0,
1163                );
1164                let une_ref = sink.required(une, 1, 0);
1165                if une_ref != group_ref {
1166                    sink.push(EdifactError::QualifierMismatch {
1167                        tag: "UNE".to_owned(),
1168                        actual: une_ref,
1169                        expected: group_ref.clone(),
1170                        span: une.span(),
1171                    });
1172                }
1173
1174                let group_content = &inner[ung_idx + 1..une_idx];
1175                let messages = extract_messages_flat(group_content, sink, &mut seen_message_refs)?;
1176                let actual_message_count = sink.recover(
1177                    u32::try_from(messages.len()).map_err(|_| EdifactError::InterchangeTooLarge {
1178                        count: messages.len() as u64,
1179                    }),
1180                    u32::MAX,
1181                );
1182
1183                if declared_message_count != actual_message_count {
1184                    sink.push(EdifactError::MessageCountMismatch {
1185                        expected: declared_message_count,
1186                        actual: actual_message_count,
1187                    });
1188                }
1189
1190                groups.push(FunctionalGroupEnvelope {
1191                    group_id,
1192                    app_sender,
1193                    app_sender_qualifier,
1194                    app_recipient,
1195                    app_recipient_qualifier,
1196                    date,
1197                    time,
1198                    group_ref,
1199                    controlling_agency,
1200                    version,
1201                    release,
1202                    application_password,
1203                    declared_message_count,
1204                    actual_message_count,
1205                    messages,
1206                });
1207                i = une_idx + 1;
1208            }
1209            "UNE" => {
1210                return Err(EdifactError::InvalidSegmentForMessage {
1211                    tag: "UNE".to_owned(),
1212                    message_type: "ENVELOPE".to_owned(),
1213                    span: seg.span(),
1214                });
1215            }
1216            "UNH" => {
1217                // §7.1: an interchange holds groups or ungrouped messages, never
1218                // both — a message outside any group has no group to be counted
1219                // in.  `CONTRL` has a code for exactly this (30), and §5.3.3
1220                // asks for the precise one over the general.
1221                return Err(EdifactError::GroupsAndMessagesMixed { span: seg.span() });
1222            }
1223            "UNO" | "UNP" => {
1224                return Err(EdifactError::PackageNotSupported {
1225                    tag: seg.tag().to_owned(),
1226                    span: seg.span(),
1227                });
1228            }
1229            _ => {
1230                return Err(EdifactError::InvalidSegmentForMessage {
1231                    tag: seg.tag().to_owned(),
1232                    message_type: "ENVELOPE".to_owned(),
1233                    span: seg.span(),
1234                });
1235            }
1236        }
1237    }
1238
1239    Ok(groups)
1240}
1241
1242/// Extract `UNH`/`UNT` message pairs from a flat slice (no UNB/UNZ/UNG/UNE expected).
1243fn extract_messages_flat<S: SegmentReader>(
1244    segments: &[S],
1245    sink: &mut ErrorSink,
1246    seen_refs: &mut HashSet<String>,
1247) -> Result<Vec<MessageEnvelope>, EdifactError> {
1248    let mut messages: Vec<MessageEnvelope> = Vec::new();
1249    // Index of the `UNH` that opened the message currently being read.  A single
1250    // `Option` carries the whole "are we inside a message" state, so the index
1251    // cannot be read while absent.
1252    let mut unh_idx: Option<usize> = None;
1253
1254    for (i, seg) in segments.iter().enumerate() {
1255        match seg.tag() {
1256            "UNH" => {
1257                if unh_idx.is_some() {
1258                    return Err(EdifactError::InvalidSegmentForMessage {
1259                        tag: "UNH".to_owned(),
1260                        message_type: "ENVELOPE".to_owned(),
1261                        span: seg.span(),
1262                    });
1263                }
1264                unh_idx = Some(i);
1265            }
1266            "UNT" if unh_idx.is_some() => {
1267                let msg_start_idx = unh_idx.take().expect("guarded by the match arm");
1268                let unh = &segments[msg_start_idx];
1269
1270                let message_ref = sink.required(unh, 0, 0);
1271                if !seen_refs.insert(message_ref.clone()) {
1272                    sink.push(EdifactError::DuplicateReference {
1273                        tag: "UNH".to_owned(),
1274                        reference: message_ref.clone(),
1275                        span: unh.span(),
1276                    });
1277                }
1278                let message_type = sink.required(unh, 1, 0);
1279                let version = sink.required(unh, 1, 1);
1280                let release = sink.required(unh, 1, 2);
1281                let controlling_agency = sink.required(unh, 1, 3);
1282                let association_code = unh.component(1, 4).unwrap_or("").to_owned();
1283                // UNH element [2]: DE 0068 — common access reference (optional)
1284                let common_access_ref = unh
1285                    .component(2, 0)
1286                    .filter(|s| !s.is_empty())
1287                    .map(str::to_owned);
1288                // UNH element [3]: S010 composite — sequence of transfers (optional)
1289                // comp[0] = DE 0070 (sequence number), comp[1] = DE 0073 (position indicator)
1290                let sequence_of_transfers = unh
1291                    .component(3, 0)
1292                    .filter(|s| !s.is_empty())
1293                    .and_then(|s| s.parse::<u32>().ok());
1294                let transfer_position = unh
1295                    .component(3, 1)
1296                    .filter(|s| !s.is_empty())
1297                    .map(str::to_owned);
1298
1299                let declared_raw = sink.required(seg, 0, 0);
1300                let declared_segment_count: u32 = sink.recover(
1301                    declared_raw.parse().map_err(|_| EdifactError::InvalidText {
1302                        offset: seg.span().start,
1303                    }),
1304                    0,
1305                );
1306                let unt_ref = sink.required(seg, 1, 0);
1307                if unt_ref != message_ref {
1308                    sink.push(EdifactError::QualifierMismatch {
1309                        tag: "UNT".to_owned(),
1310                        actual: unt_ref,
1311                        expected: message_ref.clone(),
1312                        span: seg.span(),
1313                    });
1314                }
1315
1316                let segment_span = i - msg_start_idx + 1;
1317                let actual_segment_count = sink.recover(
1318                    u32::try_from(segment_span).map_err(|_| EdifactError::InterchangeTooLarge {
1319                        count: segment_span as u64,
1320                    }),
1321                    u32::MAX,
1322                );
1323
1324                // ISO 9735-1 §7.3: a message "shall contain at least one
1325                // additional segment" beyond its header and trailer.  A bare
1326                // `UNH'UNT+2+…'` is a well-formed envelope around nothing, and
1327                // its own segment count confirms it — so only this rule sees it.
1328                if segment_span < 3 {
1329                    sink.push(EdifactError::EmptyMessage {
1330                        message_ref: message_ref.clone(),
1331                        span: unh.span(),
1332                    });
1333                }
1334
1335                messages.push(MessageEnvelope {
1336                    message_ref,
1337                    message_type,
1338                    version,
1339                    release,
1340                    controlling_agency,
1341                    association_code,
1342                    common_access_ref,
1343                    sequence_of_transfers,
1344                    transfer_position,
1345                    declared_segment_count,
1346                    actual_segment_count,
1347                    header_span: unh.span(),
1348                    trailer_span: seg.span(),
1349                });
1350            }
1351            "UNT" => {
1352                return Err(EdifactError::InvalidSegmentForMessage {
1353                    tag: "UNT".to_owned(),
1354                    message_type: "ENVELOPE".to_owned(),
1355                    span: seg.span(),
1356                });
1357            }
1358            // A package is a legal member of an interchange (§7.9), so reporting
1359            // it as a stray segment would send the reader looking for a
1360            // corruption that is not there.  The object between UNO and UNP is
1361            // arbitrary binary and is not EDIFACT-encoded, which is the reason
1362            // this crate declines it rather than the reason it is invalid.
1363            "UNO" | "UNP" => {
1364                return Err(EdifactError::PackageNotSupported {
1365                    tag: seg.tag().to_owned(),
1366                    span: seg.span(),
1367                });
1368            }
1369            "UNB" | "UNZ" | "UNG" | "UNE" if unh_idx.is_some() => {
1370                return Err(EdifactError::InvalidSegmentForMessage {
1371                    tag: seg.tag().to_owned(),
1372                    message_type: "ENVELOPE".to_owned(),
1373                    span: seg.span(),
1374                });
1375            }
1376            _ if unh_idx.is_none() => {
1377                return Err(EdifactError::InvalidSegmentForMessage {
1378                    tag: seg.tag().to_owned(),
1379                    message_type: "ENVELOPE".to_owned(),
1380                    span: seg.span(),
1381                });
1382            }
1383            _ => {}
1384        }
1385    }
1386
1387    if unh_idx.is_some() {
1388        return Err(EdifactError::MissingSegment {
1389            tag: "UNT".to_owned(),
1390            expected_position: "end of message group".to_owned(),
1391        });
1392    }
1393
1394    Ok(messages)
1395}
1396
1397// ── Tests ─────────────────────────────────────────────────────────────────────
1398
1399#[cfg(test)]
1400mod tests {
1401    use super::*;
1402
1403    fn parse(input: &[u8]) -> Vec<crate::OwnedSegment> {
1404        crate::from_reader_collect(std::io::Cursor::new(input)).expect("parse failed")
1405    }
1406
1407    fn parse_and_validate(input: &[u8]) -> Result<ValidatedInterchange, EdifactError> {
1408        let owned = parse(input);
1409        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1410        validate_envelope(&segs)
1411    }
1412
1413    fn parse_and_validate_lenient(input: &[u8]) -> LenientResult {
1414        let owned = parse(input);
1415        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1416        validate_envelope_lenient(&segs)
1417    }
1418
1419    #[test]
1420    fn lenient_collects_every_violation_not_just_the_first() {
1421        // Two independent violations: a UNZ control-reference mismatch and a
1422        // UNT segment-count mismatch.  The lenient path used to abort on the
1423        // first and report only one.
1424        let input = b"UNB+UNOA:1+S+R+200101:0900+CTRL1'\
1425                      UNH+1+ORDERS:D:96A:UN'BGM+220'UNT+99+1'\
1426                      UNZ+1+CTRL2'";
1427        let result = parse_and_validate_lenient(input);
1428        assert!(
1429            result.errors.len() >= 2,
1430            "expected both violations, got {:?}",
1431            result.errors
1432        );
1433        assert!(
1434            result
1435                .errors
1436                .iter()
1437                .any(|e| matches!(e, EdifactError::QualifierMismatch { tag, .. } if tag == "UNZ")),
1438            "missing UNZ control-ref mismatch in {:?}",
1439            result.errors
1440        );
1441        assert!(
1442            result
1443                .errors
1444                .iter()
1445                .any(|e| matches!(e, EdifactError::SegmentCountMismatch { .. })),
1446            "missing UNT segment-count mismatch in {:?}",
1447            result.errors
1448        );
1449    }
1450
1451    #[test]
1452    fn strict_reports_the_first_error_lenient_collects() {
1453        // Strict and lenient share one implementation, so strict's error must
1454        // always be the head of the lenient error list.
1455        for input in [
1456            &b"UNB+UNOA:1+S+R+200101:0900+C1'UNH+1+ORDERS:D:96A:UN'UNT+2+1'UNZ+9+C1'"[..],
1457            &b"UNB+UNOA:1+S+R+200101:0900+C1'UNH+1+ORDERS:D:96A:UN'UNT+99+1'UNZ+1+C1'"[..],
1458            &b"UNB+UNOA:1+S+R+200101:0900+C1'UNH+1+ORDERS:D:96A:UN'UNT+2+9'UNZ+1+C1'"[..],
1459        ] {
1460            let strict = parse_and_validate(input);
1461            let lenient = parse_and_validate_lenient(input);
1462            match strict {
1463                Err(e) => assert_eq!(
1464                    Some(&e),
1465                    lenient.errors.first(),
1466                    "strict/lenient diverged for {:?}",
1467                    std::str::from_utf8(input).unwrap()
1468                ),
1469                Ok(_) => assert!(lenient.errors.is_empty()),
1470            }
1471        }
1472    }
1473
1474    #[test]
1475    fn duplicate_message_references_are_rejected() {
1476        let input = b"UNB+UNOA:1+S+R+200101:0900+C1'\
1477                      UNH+1+ORDERS:D:96A:UN'BGM+220'UNT+3+1'\
1478                      UNH+1+ORDERS:D:96A:UN'BGM+221'UNT+3+1'\
1479                      UNZ+2+C1'";
1480        let err = parse_and_validate(input).expect_err("duplicate UNH refs must fail");
1481        assert!(
1482            matches!(&err, EdifactError::DuplicateReference { tag, reference, .. }
1483                     if tag == "UNH" && reference == "1"),
1484            "got {err:?}"
1485        );
1486    }
1487
1488    #[test]
1489    fn distinct_message_references_are_accepted() {
1490        let input = b"UNB+UNOA:1+S+R+200101:0900+C1'\
1491                      UNH+1+ORDERS:D:96A:UN'BGM+220'UNT+3+1'\
1492                      UNH+2+ORDERS:D:96A:UN'BGM+221'UNT+3+2'\
1493                      UNZ+2+C1'";
1494        parse_and_validate(input).expect("distinct refs must validate");
1495    }
1496
1497    #[test]
1498    fn into_strict_is_total_after_the_caller_filters_errors() {
1499        // `errors` is a public field, so filtering tolerated violations before
1500        // converting must not panic.
1501        let input = b"UNB+UNOA:1+S+R+200101:0900+C1'UNZ+1+C2'";
1502        let mut lenient = parse_and_validate_lenient(input);
1503        lenient.errors.clear();
1504        // Either outcome is acceptable; the contract is only that it does not panic.
1505        let _ = lenient.into_strict();
1506    }
1507
1508    fn parse_and_validate_owned(input: &[u8]) -> Result<ValidatedInterchange, EdifactError> {
1509        validate_envelope_from_owned(&parse(input))
1510    }
1511
1512    const VALID_INTERCHANGE: &[u8] =
1513        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'";
1514
1515    #[test]
1516    fn valid_envelope_parses_ok() {
1517        let result = parse_and_validate(VALID_INTERCHANGE).expect("envelope should be valid");
1518        assert_eq!(result.interchange.sender_id, "SENDER");
1519        assert_eq!(result.interchange.sender_qualifier, ""); // no qualifier in fixture
1520        assert_eq!(result.interchange.recipient_id, "RECEIVER");
1521        assert_eq!(result.interchange.recipient_qualifier, "");
1522        assert_eq!(result.interchange.syntax_identifier, "UNOA");
1523        assert_eq!(result.interchange.syntax_version, "3");
1524        assert_eq!(result.interchange.control_ref, "00001");
1525        assert_eq!(result.interchange.declared_unit_count, 1);
1526        assert_eq!(result.interchange.actual_unit_count, 1);
1527        assert!(!result.interchange.is_test());
1528        assert!(!result.has_functional_groups());
1529        assert_eq!(result.message_count(), 1);
1530        assert_eq!(result.messages[0].message_type, "ORDERS");
1531        assert_eq!(result.messages[0].association_code, "EAN010");
1532        assert_eq!(result.messages[0].declared_segment_count, 4);
1533        assert_eq!(result.messages[0].actual_segment_count, 4);
1534    }
1535
1536    #[test]
1537    fn valid_envelope_parses_ok_owned_path() {
1538        let result = parse_and_validate_owned(VALID_INTERCHANGE).expect("envelope should be valid");
1539        assert_eq!(result.interchange.sender_id, "SENDER");
1540        assert_eq!(result.interchange.actual_unit_count, 1);
1541        assert_eq!(result.messages[0].declared_segment_count, 4);
1542    }
1543
1544    #[test]
1545    fn unt_count_mismatch_returns_err() {
1546        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'";
1547        let result = parse_and_validate(input);
1548        assert!(
1549            matches!(
1550                result,
1551                Err(EdifactError::SegmentCountMismatch { expected: 99, .. })
1552            ),
1553            "expected SegmentCountMismatch, got {result:?}"
1554        );
1555    }
1556
1557    #[test]
1558    fn unz_count_mismatch_returns_err() {
1559        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'";
1560        let result = parse_and_validate(input);
1561        assert!(
1562            matches!(
1563                result,
1564                Err(EdifactError::MessageCountMismatch {
1565                    expected: 2,
1566                    actual: 1
1567                })
1568            ),
1569            "expected MessageCountMismatch(2,1), got {result:?}"
1570        );
1571    }
1572
1573    #[test]
1574    fn missing_unb_returns_err() {
1575        let input = b"UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
1576        assert!(parse_and_validate(input).is_err());
1577    }
1578
1579    #[test]
1580    fn extracts_una_interchange_correctly() {
1581        let result = parse_and_validate(VALID_INTERCHANGE).unwrap();
1582        assert_eq!(result.interchange.syntax_identifier, "UNOA");
1583        assert_eq!(result.interchange.syntax_version, "3");
1584        assert_eq!(result.interchange.date, "230401");
1585        assert_eq!(result.interchange.time.as_deref(), Some("0900"));
1586    }
1587
1588    #[test]
1589    fn sender_and_recipient_qualifiers_extracted() {
1590        // GLN-qualified partners: 1234567890123:14 — qualifier at S002 comp 1
1591        let input = b"UNB+UNOA:3+1234567890123:14+9876543210987:14+200101:0900+1'\
1592                      UNH+1+ORDERS:D:96A:UN'\
1593                      BGM+220+PO-001+9'\
1594                      UNT+3+1'\
1595                      UNZ+1+1'";
1596        let r = parse_and_validate(input).expect("GLN-qualified UNB must parse ok");
1597        assert_eq!(r.interchange.sender_id, "1234567890123");
1598        assert_eq!(r.interchange.sender_qualifier, "14");
1599        assert_eq!(r.interchange.recipient_id, "9876543210987");
1600        assert_eq!(r.interchange.recipient_qualifier, "14");
1601    }
1602
1603    // UNB DE 0026 (app_ref) is at element index 6 (ISO 9735-1 Annex C.1.5 (UNB)):
1604    // [4]=control_ref [5]=S005/password [6]=0026/app_ref [7]=0029 [8]=0031/ack [9]=0032/comms [10]=0035/test
1605
1606    #[test]
1607    fn test_indicator_parsed_from_unb() {
1608        // DE 0035 (test indicator) is at element index 10 (ISO 9735-1).
1609        // UNB+...+1 (ctrl) + (S005) + (0026) + (0029) + (0031) + (0032) + 1 (0035)
1610        //                    [5]       [6]       [7]       [8]       [9]     [10]
1611        let input = b"UNB+UNOA:3+S+R+200101:0900+1++++++1'\
1612                      UNH+1+ORDERS:D:96A:UN'\
1613                      BGM+220+PO-001+9'\
1614                      UNT+3+1'\
1615                      UNZ+1+1'";
1616        let r = parse_and_validate(input).expect("test-flagged UNB must parse ok");
1617        assert!(
1618            r.interchange.test_indicator,
1619            "test_indicator should be true"
1620        );
1621        assert!(r.interchange.is_test(), "is_test() convenience must agree");
1622    }
1623
1624    #[test]
1625    fn no_test_indicator_defaults_false() {
1626        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1627        assert!(!r.interchange.test_indicator);
1628        assert!(!r.interchange.is_test());
1629    }
1630
1631    #[test]
1632    fn app_ref_extracted_when_present() {
1633        // DE 0026 (app_ref) is at element index 6; element [5] (S005 password) is empty.
1634        let input = b"UNB+UNOA:3+S+R+200101:0900+1++MYAPP'\
1635                      UNH+1+ORDERS:D:96A:UN'\
1636                      BGM+220+PO-001+9'\
1637                      UNT+3+1'\
1638                      UNZ+1+1'";
1639        let r = parse_and_validate(input).expect("UNB with app_ref must parse ok");
1640        assert_eq!(r.interchange.app_ref.as_deref(), Some("MYAPP"));
1641    }
1642
1643    #[test]
1644    fn app_ref_is_none_when_absent() {
1645        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1646        assert!(r.interchange.app_ref.is_none());
1647    }
1648
1649    #[test]
1650    fn recipient_password_extracted_when_present() {
1651        // DE 0022 (recipient password) is at element index 5 (S005 comp 0).
1652        let input = b"UNB+UNOA:3+S+R+200101:0900+1+MYPASS'\
1653                      UNH+1+ORDERS:D:96A:UN'\
1654                      BGM+220+PO-001+9'\
1655                      UNT+3+1'\
1656                      UNZ+1+1'";
1657        let r = parse_and_validate(input).expect("UNB with password must parse ok");
1658        assert_eq!(r.interchange.recipient_password.as_deref(), Some("MYPASS"));
1659    }
1660
1661    #[test]
1662    fn recipient_password_is_none_when_absent() {
1663        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1664        assert!(r.interchange.recipient_password.is_none());
1665    }
1666
1667    #[test]
1668    fn acknowledgement_request_flag_parsed() {
1669        // DE 0031 (ack request) at element index 8; set to "1".
1670        // Elements: [5]S005="" [6]0026="" [7]0029="" [8]0031="1"
1671        let input = b"UNB+UNOA:3+S+R+200101:0900+1++++1'\
1672                      UNH+1+ORDERS:D:96A:UN'\
1673                      BGM+220+PO-001+9'\
1674                      UNT+3+1'\
1675                      UNZ+1+1'";
1676        let r = parse_and_validate(input).expect("UNB with ack-request must parse ok");
1677        assert!(r.interchange.acknowledgement_request);
1678        assert!(r.interchange.ack_requested());
1679    }
1680
1681    #[test]
1682    fn acknowledgement_request_defaults_false() {
1683        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1684        assert!(!r.interchange.acknowledgement_request);
1685        assert!(!r.interchange.ack_requested());
1686    }
1687
1688    #[test]
1689    fn communications_agreement_id_extracted() {
1690        // DE 0032 at element index 9; elements [5]-[8] empty.
1691        let input = b"UNB+UNOA:3+S+R+200101:0900+1+++++AGREEMENT-1'\
1692                      UNH+1+ORDERS:D:96A:UN'\
1693                      BGM+220+PO-001+9'\
1694                      UNT+3+1'\
1695                      UNZ+1+1'";
1696        let r = parse_and_validate(input).expect("UNB with comms-agreement must parse ok");
1697        assert_eq!(
1698            r.interchange.communications_agreement_id.as_deref(),
1699            Some("AGREEMENT-1")
1700        );
1701    }
1702
1703    #[test]
1704    fn dangling_unh_without_unt_returns_err() {
1705        let input =
1706            b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNZ+1+1'";
1707        let result = parse_and_validate(input);
1708        assert!(
1709            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNT")
1710        );
1711    }
1712
1713    #[test]
1714    fn stray_segment_outside_message_returns_err() {
1715        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'";
1716        assert!(matches!(
1717            parse_and_validate(input),
1718            Err(EdifactError::InvalidSegmentForMessage { .. })
1719        ));
1720    }
1721
1722    #[test]
1723    fn missing_unb_sender_component_returns_err() {
1724        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'";
1725        let result = parse_and_validate(input);
1726        assert!(
1727            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 0 }) if tag == "UNB"),
1728            "expected MissingRequiredComponent for empty sender, got: {result:?}"
1729        );
1730    }
1731
1732    #[test]
1733    fn nested_unh_without_closing_previous_message_returns_err() {
1734        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'";
1735        let result = parse_and_validate(input);
1736        assert!(
1737            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNH"),
1738            "expected InvalidSegmentForMessage(UNH), got {result:?}"
1739        );
1740    }
1741
1742    #[test]
1743    fn unt_message_reference_must_match_unh() {
1744        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'";
1745        let result = parse_and_validate(input);
1746        assert!(
1747            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNT")
1748        );
1749    }
1750
1751    #[test]
1752    fn unz_control_reference_must_match_unb() {
1753        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'";
1754        let result = parse_and_validate(input);
1755        assert!(
1756            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNZ")
1757        );
1758    }
1759
1760    #[test]
1761    fn missing_unh_message_type_components_return_err() {
1762        let input =
1763            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'";
1764        let result = parse_and_validate(input);
1765        assert!(
1766            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 3 }) if tag == "UNH"),
1767            "got: {result:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn nested_unz_inside_message_returns_err() {
1773        let input =
1774            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'";
1775        let result = parse_and_validate(input);
1776        assert!(
1777            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNZ")
1778        );
1779    }
1780
1781    #[test]
1782    fn lenient_returns_partial_result_on_count_mismatch() {
1783        // UNZ says 2 but only 1 message — lenient mode must return Some(partial)
1784        // along with the error, not None.
1785        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'";
1786        let owned = parse(input);
1787        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1788        let lenient = validate_envelope_lenient(&segs);
1789        let result = lenient.interchange;
1790        let errors = lenient.errors;
1791        assert!(
1792            result.is_some(),
1793            "lenient mode must return Some even on count mismatch"
1794        );
1795        assert_eq!(errors.len(), 1);
1796        assert!(
1797            matches!(
1798                &errors[0],
1799                EdifactError::MessageCountMismatch {
1800                    expected: 2,
1801                    actual: 1
1802                }
1803            ),
1804            "expected MessageCountMismatch(2,1), got {:?}",
1805            errors[0]
1806        );
1807        let partial = result.unwrap();
1808        assert_eq!(partial.messages.len(), 1);
1809        assert_eq!(partial.interchange.actual_unit_count, 1);
1810        assert_eq!(partial.interchange.declared_unit_count, 2);
1811    }
1812
1813    #[test]
1814    fn lenient_returns_none_on_structural_error() {
1815        // Missing UNB — no structure at all, expect None
1816        let input = b"UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
1817        let owned = parse(input);
1818        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1819        let lenient = validate_envelope_lenient(&segs);
1820        let result = lenient.interchange;
1821        let errors = lenient.errors;
1822        assert!(result.is_none(), "missing UNB must yield None");
1823        assert!(!errors.is_empty());
1824    }
1825
1826    #[test]
1827    fn message_count_convenience_method() {
1828        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1829        assert_eq!(r.message_count(), r.messages.len());
1830        assert_eq!(r.message_count(), 1);
1831    }
1832
1833    #[test]
1834    fn interchange_with_single_functional_group_parses_ok() {
1835        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1836                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1837                      UNH+1+ORDERS:D:96A:UN'\
1838                      BGM+220+PO-001+9'\
1839                      UNT+3+1'\
1840                      UNE+1+1'\
1841                      UNZ+1+1'";
1842        let result = parse_and_validate(input).expect("single-group interchange must parse ok");
1843        assert!(result.has_functional_groups());
1844        assert_eq!(result.functional_groups.len(), 1);
1845        let g = &result.functional_groups[0];
1846        assert_eq!(g.group_id, "ORDERS");
1847        assert_eq!(g.group_ref, "1");
1848        assert_eq!(g.controlling_agency, "UN");
1849        assert_eq!(g.declared_message_count, 1);
1850        assert_eq!(g.actual_message_count, 1);
1851        assert_eq!(result.messages.len(), 1);
1852        assert_eq!(result.messages[0].message_type, "ORDERS");
1853        assert_eq!(result.interchange.declared_unit_count, 1);
1854        assert_eq!(result.interchange.actual_unit_count, 1);
1855    }
1856
1857    #[test]
1858    fn interchange_with_multi_message_group_parses_ok() {
1859        // One group containing 2 messages — UNZ = 1 group, UNE = 2 messages.
1860        // This is the key case that strip_functional_group_segments breaks.
1861        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1862                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1863                      UNH+1+ORDERS:D:96A:UN'\
1864                      BGM+220+PO-001+9'\
1865                      UNT+3+1'\
1866                      UNH+2+ORDERS:D:96A:UN'\
1867                      BGM+220+PO-002+9'\
1868                      UNT+3+2'\
1869                      UNE+2+1'\
1870                      UNZ+1+1'";
1871        let result = parse_and_validate(input).expect("multi-message group must parse ok");
1872        assert!(result.has_functional_groups());
1873        assert_eq!(result.functional_groups.len(), 1);
1874        assert_eq!(result.functional_groups[0].actual_message_count, 2);
1875        assert_eq!(result.messages.len(), 2);
1876        // UNZ = 1 group (not 2 messages): ISO 9735-1 Annex C.3.4 (DE 0036)
1877        assert_eq!(result.interchange.actual_unit_count, 1);
1878        assert_eq!(result.interchange.declared_unit_count, 1);
1879    }
1880
1881    #[test]
1882    fn interchange_with_multiple_groups_parses_ok() {
1883        let input = b"UNB+UNOA:3+S+R+200101:0900+2'\
1884                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1885                      UNH+1+ORDERS:D:96A:UN'\
1886                      BGM+220+PO-001+9'\
1887                      UNT+3+1'\
1888                      UNE+1+1'\
1889                      UNG+INVOIC+S+R+200101:0900+2+UN+D:96A'\
1890                      UNH+2+INVOIC:D:96A:UN'\
1891                      BGM+380+INV-001+9'\
1892                      UNT+3+2'\
1893                      UNE+1+2'\
1894                      UNZ+2+2'";
1895        let result = parse_and_validate(input).expect("multi-group interchange must parse ok");
1896        assert_eq!(result.functional_groups.len(), 2);
1897        assert_eq!(result.functional_groups[0].group_id, "ORDERS");
1898        assert_eq!(result.functional_groups[1].group_id, "INVOIC");
1899        assert_eq!(result.messages.len(), 2);
1900        assert_eq!(result.interchange.declared_unit_count, 2);
1901        assert_eq!(result.interchange.actual_unit_count, 2);
1902    }
1903
1904    #[test]
1905    fn ung_une_count_mismatch_returns_err() {
1906        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1907                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1908                      UNH+1+ORDERS:D:96A:UN'\
1909                      BGM+220+PO-001+9'\
1910                      UNT+3+1'\
1911                      UNE+2+1'\
1912                      UNZ+1+1'";
1913        let result = parse_and_validate(input);
1914        assert!(
1915            matches!(
1916                result,
1917                Err(EdifactError::MessageCountMismatch {
1918                    expected: 2,
1919                    actual: 1
1920                })
1921            ),
1922            "expected MessageCountMismatch(2,1), got {result:?}"
1923        );
1924    }
1925
1926    #[test]
1927    fn une_without_ung_returns_err() {
1928        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1929                      UNE+1+1'\
1930                      UNH+1+ORDERS:D:96A:UN'\
1931                      BGM+220+PO-001+9'\
1932                      UNT+3+1'\
1933                      UNZ+1+1'";
1934        let result = parse_and_validate(input);
1935        assert!(
1936            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNE"),
1937            "expected InvalidSegmentForMessage(UNE), got {result:?}"
1938        );
1939    }
1940
1941    #[test]
1942    fn ung_without_une_returns_err() {
1943        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1944                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1945                      UNH+1+ORDERS:D:96A:UN'\
1946                      BGM+220+PO-001+9'\
1947                      UNT+3+1'\
1948                      UNZ+1+1'";
1949        let result = parse_and_validate(input);
1950        assert!(
1951            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNE"),
1952            "expected MissingSegment(UNE), got {result:?}"
1953        );
1954    }
1955
1956    #[test]
1957    fn une_group_ref_must_match_ung() {
1958        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1959                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1960                      UNH+1+ORDERS:D:96A:UN'\
1961                      BGM+220+PO-001+9'\
1962                      UNT+3+1'\
1963                      UNE+1+999'\
1964                      UNZ+1+1'";
1965        let result = parse_and_validate(input);
1966        assert!(
1967            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNE"),
1968            "expected QualifierMismatch(UNE), got {result:?}"
1969        );
1970    }
1971
1972    // ── New-field tests (ISO 9735-1 completeness) ─────────────────────────────
1973
1974    #[test]
1975    fn processing_priority_extracted_when_present() {
1976        // DE 0029 at element index 7: [5]=S005="" [6]=0026="" [7]=0029="A"
1977        let input = b"UNB+UNOA:3+S+R+200101:0900+1+++A'\
1978                      UNH+1+ORDERS:D:96A:UN'\
1979                      BGM+220+PO-001+9'\
1980                      UNT+3+1'\
1981                      UNZ+1+1'";
1982        let r = parse_and_validate(input).expect("UNB with processing_priority must parse ok");
1983        assert_eq!(r.interchange.processing_priority.as_deref(), Some("A"));
1984    }
1985
1986    #[test]
1987    fn processing_priority_is_none_when_absent() {
1988        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1989        assert!(r.interchange.processing_priority.is_none());
1990    }
1991
1992    #[test]
1993    fn sender_routing_address_extracted_when_present() {
1994        // S002: sender_id:qualifier:routing  → comp[2] = routing address
1995        let input = b"UNB+UNOA:3+SENDER:14:ROUTEA+RECIP+200101:0900+1'\
1996                      UNH+1+ORDERS:D:96A:UN'\
1997                      BGM+220+PO-001+9'\
1998                      UNT+3+1'\
1999                      UNZ+1+1'";
2000        let r = parse_and_validate(input).expect("UNB with sender routing must parse ok");
2001        assert_eq!(
2002            r.interchange.sender_routing_address.as_deref(),
2003            Some("ROUTEA")
2004        );
2005        assert!(r.interchange.recipient_routing_address.is_none());
2006    }
2007
2008    #[test]
2009    fn recipient_routing_address_extracted_when_present() {
2010        // S003: recipient_id:qualifier:routing → comp[2] = routing address
2011        let input = b"UNB+UNOA:3+SENDER+RECIP:14:ROUTEB+200101:0900+1'\
2012                      UNH+1+ORDERS:D:96A:UN'\
2013                      BGM+220+PO-001+9'\
2014                      UNT+3+1'\
2015                      UNZ+1+1'";
2016        let r = parse_and_validate(input).expect("UNB with recipient routing must parse ok");
2017        assert!(r.interchange.sender_routing_address.is_none());
2018        assert_eq!(
2019            r.interchange.recipient_routing_address.as_deref(),
2020            Some("ROUTEB")
2021        );
2022    }
2023
2024    #[test]
2025    fn routing_address_is_none_when_absent() {
2026        // Plain S+R without sub-components — no routing addresses in S002/S003
2027        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2028                      UNH+1+ORDERS:D:96A:UN'\
2029                      BGM+220+PO-001+9'\
2030                      UNT+3+1'\
2031                      UNZ+1+1'";
2032        let r = parse_and_validate(input).unwrap();
2033        assert!(r.interchange.sender_routing_address.is_none());
2034        assert!(r.interchange.recipient_routing_address.is_none());
2035    }
2036
2037    #[test]
2038    fn common_access_ref_extracted_when_present() {
2039        // UNH element [2] (DE 0068): common access reference
2040        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2041                      UNH+1+ORDERS:D:96A:UN+COMREF1'\
2042                      BGM+220+PO-001+9'\
2043                      UNT+3+1'\
2044                      UNZ+1+1'";
2045        let r = parse_and_validate(input).expect("UNH with common_access_ref must parse ok");
2046        assert_eq!(r.messages[0].common_access_ref.as_deref(), Some("COMREF1"));
2047    }
2048
2049    #[test]
2050    fn common_access_ref_is_none_when_absent() {
2051        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2052        assert!(r.messages[0].common_access_ref.is_none());
2053    }
2054
2055    #[test]
2056    fn parse_unh_includes_message_ref() {
2057        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2058                      UNH+REF42+ORDERS:D:96A:UN'\
2059                      BGM+220+PO-001+9'\
2060                      UNT+3+REF42'\
2061                      UNZ+1+1'";
2062        // Validate the high-level API which exercises parse_unh internally;
2063        // the message_ref field on MessageEnvelope should equal the UNH DE 0062 value.
2064        let r = parse_and_validate(input).expect("must parse ok");
2065        assert_eq!(r.messages[0].message_ref, "REF42");
2066        assert_eq!(r.messages[0].message_type, "ORDERS");
2067    }
2068
2069    #[test]
2070    fn iter_messages_by_type_returns_matching_messages() {
2071        let input = b"UNB+UNOA:3+S+R+200101:0900+CTRL2'\
2072                      UNH+1+ORDERS:D:96A:UN'\
2073                      BGM+220+PO-001+9'\
2074                      UNT+3+1'\
2075                      UNH+2+INVOIC:D:96A:UN'\
2076                      BGM+380+INV-001+9'\
2077                      UNT+3+2'\
2078                      UNZ+2+CTRL2'";
2079        let r = parse_and_validate(input).expect("two-message interchange must parse ok");
2080        let orders: Vec<_> = r.iter_messages_by_type("ORDERS").collect();
2081        assert_eq!(orders.len(), 1);
2082        assert_eq!(orders[0].message_ref, "1");
2083        let invoices: Vec<_> = r.iter_messages_by_type("INVOIC").collect();
2084        assert_eq!(invoices.len(), 1);
2085        assert_eq!(invoices[0].message_ref, "2");
2086        let none: Vec<_> = r.iter_messages_by_type("DESADV").collect();
2087        assert!(none.is_empty());
2088    }
2089
2090    #[test]
2091    fn display_interchange_envelope() {
2092        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2093        let s = r.interchange.to_string();
2094        assert!(s.contains("SENDER"), "Display must include sender_id");
2095        assert!(s.contains("UNOA"), "Display must include syntax_identifier");
2096    }
2097
2098    #[test]
2099    fn display_message_envelope() {
2100        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2101        let s = r.messages[0].to_string();
2102        assert!(s.contains("ORDERS"), "Display must include message_type");
2103        assert!(s.contains("ref="), "Display must include message ref label");
2104    }
2105
2106    #[test]
2107    fn display_validated_interchange() {
2108        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2109        let s = r.to_string();
2110        assert!(
2111            s.contains("messages="),
2112            "Display must include message count label"
2113        );
2114    }
2115
2116    // ── DE 0001 syntax identifier validation ─────────────────────────────────
2117
2118    #[test]
2119    fn unrecognised_syntax_identifier_returns_err() {
2120        // DE 0001 is `UN` plus a two-character repertoire code; "XXXX" names none.
2121        let input = b"UNB+XXXX:3+S+R+200101:0900+1'\
2122                      UNH+1+ORDERS:D:96A:UN'\
2123                      BGM+220+PO-001+9'\
2124                      UNT+3+1'\
2125                      UNZ+1+1'";
2126        let result = parse_and_validate(input);
2127        assert!(
2128            matches!(result, Err(EdifactError::UnrecognisedSyntaxIdentifier(ref id)) if id == "XXXX"),
2129            "expected UnrecognisedSyntaxIdentifier(\"XXXX\"), got {result:?}"
2130        );
2131    }
2132
2133    #[test]
2134    fn every_repertoire_the_crate_decodes_is_accepted_in_the_unb() {
2135        // The envelope validator used to keep its own list of syntax
2136        // identifiers, which stopped at UNOF — so `UNOY`, the UTF-8 repertoire
2137        // this crate decodes and documents, was rejected as unrecognised while
2138        // `Charset` handled it happily.  One list, in `Charset`, now decides.
2139        for id in [
2140            "UNOA", "UNOB", "UNOC", "UNOD", "UNOE", "UNOF", "UNOG", "UNOH", "UNOI", "UNOJ", "UNOK",
2141            "UNOY",
2142        ] {
2143            let input = format!(
2144                "UNB+{id}:3+S+R+200101:0900+1'UNH+1+ORDERS:D:96A:UN'BGM+220+PO-001+9'UNT+3+1'UNZ+1+1'"
2145            );
2146            let result = parse_and_validate(input.as_bytes());
2147            assert!(
2148                result.is_ok(),
2149                "syntax id '{id}' should be accepted, got {result:?}"
2150            );
2151        }
2152    }
2153
2154    #[test]
2155    fn a_defined_but_undecodable_repertoire_says_so() {
2156        // `UNOX` and `KECA` are real ISO 9735 syntax identifiers that this crate
2157        // cannot decode.  "Unsupported" and "unrecognised" are different claims:
2158        // one is about this crate, the other about the sender.
2159        for id in ["UNOX", "KECA"] {
2160            let input = format!(
2161                "UNB+{id}:3+S+R+200101:0900+1'UNH+1+ORDERS:D:96A:UN'BGM+220+PO-001+9'UNT+3+1'UNZ+1+1'"
2162            );
2163            let result = parse_and_validate(input.as_bytes());
2164            assert!(
2165                matches!(result, Err(EdifactError::UnsupportedCharset { ref syntax_identifier }) if syntax_identifier == id),
2166                "expected UnsupportedCharset for '{id}', got {result:?}"
2167            );
2168        }
2169    }
2170
2171    #[test]
2172    fn an_interchange_with_no_content_is_rejected() {
2173        // ISO 9735-1 §7.1.  `UNZ+0` makes the control count agree, so this is
2174        // invisible to every other check.
2175        let result = parse_and_validate(b"UNB+UNOC:3+S+R+260101:0900+IC1'UNZ+0+IC1'");
2176        assert!(
2177            matches!(result, Err(EdifactError::EmptyInterchange { ref control_ref }) if control_ref == "IC1"),
2178            "expected EmptyInterchange, got {result:?}"
2179        );
2180    }
2181
2182    #[test]
2183    fn a_message_with_no_body_is_rejected() {
2184        // ISO 9735-1 §7.3: a message "shall contain at least one additional
2185        // segment".  `UNT+2` is self-consistent, so only this rule sees it.
2186        let result = parse_and_validate(
2187            b"UNB+UNOC:3+S+R+260101:0900+IC1'UNH+1+ORDERS:D:96A:UN'UNT+2+1'UNZ+1+IC1'",
2188        );
2189        assert!(
2190            matches!(result, Err(EdifactError::EmptyMessage { ref message_ref, .. }) if message_ref == "1"),
2191            "expected EmptyMessage, got {result:?}"
2192        );
2193    }
2194
2195    #[test]
2196    fn a_package_is_reported_as_a_package() {
2197        // Not as a stray segment: `UNO` is a legal member of an interchange
2198        // (§7.9), and saying "invalid segment for message type ENVELOPE" sends
2199        // the reader hunting for a corruption that is not there.
2200        let result = parse_and_validate(
2201            b"UNB+UNOC:4+S+R+260101:0900+IC1'UNO+PKG1+AAF:1+ZZZ+1024'UNZ+1+IC1'",
2202        );
2203        assert!(
2204            matches!(result, Err(EdifactError::PackageNotSupported { ref tag, .. }) if tag == "UNO"),
2205            "expected PackageNotSupported, got {result:?}"
2206        );
2207    }
2208
2209    // ── UNB S005 password qualifier ───────────────────────────────────────────
2210
2211    #[test]
2212    fn recipient_password_qualifier_extracted_when_present() {
2213        // S005: MYPASS:AA — comp[0]=password, comp[1]=qualifier (DE 0025)
2214        let input = b"UNB+UNOA:3+S+R+200101:0900+1+MYPASS:AA'\
2215                      UNH+1+ORDERS:D:96A:UN'\
2216                      BGM+220+PO-001+9'\
2217                      UNT+3+1'\
2218                      UNZ+1+1'";
2219        let r = parse_and_validate(input).expect("UNB with password+qualifier must parse ok");
2220        assert_eq!(r.interchange.recipient_password.as_deref(), Some("MYPASS"));
2221        assert_eq!(
2222            r.interchange.recipient_password_qualifier.as_deref(),
2223            Some("AA")
2224        );
2225    }
2226
2227    #[test]
2228    fn recipient_password_qualifier_is_none_when_absent() {
2229        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2230        assert!(r.interchange.recipient_password_qualifier.is_none());
2231    }
2232
2233    // ── UNH S010 sequence-of-transfers ───────────────────────────────────────
2234
2235    #[test]
2236    fn sequence_of_transfers_extracted_when_present() {
2237        // UNH element [2] = common access ref, element [3] = S010 (seq:position)
2238        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2239                      UNH+1+ORDERS:D:96A:UN++2:C'\
2240                      BGM+220+PO-001+9'\
2241                      UNT+3+1'\
2242                      UNZ+1+1'";
2243        let r = parse_and_validate(input).expect("UNH with S010 must parse ok");
2244        assert_eq!(r.messages[0].sequence_of_transfers, Some(2));
2245        assert_eq!(r.messages[0].transfer_position.as_deref(), Some("C"));
2246    }
2247
2248    #[test]
2249    fn sequence_of_transfers_none_when_absent() {
2250        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2251        assert!(r.messages[0].sequence_of_transfers.is_none());
2252        assert!(r.messages[0].transfer_position.is_none());
2253    }
2254
2255    // ── UNG S006/S007 application qualifiers ─────────────────────────────────
2256
2257    #[test]
2258    fn ung_app_sender_and_recipient_qualifiers_extracted() {
2259        // UNG: group_id + S006(app_sender:qualifier) + S007(app_recip:qualifier) + ...
2260        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2261                      UNG+ORDERS+APPSEND:ZZZ+APPRECV:14+200101:0900+1+UN+D:96A'\
2262                      UNH+1+ORDERS:D:96A:UN'\
2263                      BGM+220+PO-001+9'\
2264                      UNT+3+1'\
2265                      UNE+1+1'\
2266                      UNZ+1+1'";
2267        let r = parse_and_validate(input).expect("UNG with qualifiers must parse ok");
2268        let g = &r.functional_groups[0];
2269        assert_eq!(g.app_sender, "APPSEND");
2270        assert_eq!(g.app_sender_qualifier, "ZZZ");
2271        assert_eq!(g.app_recipient, "APPRECV");
2272        assert_eq!(g.app_recipient_qualifier, "14");
2273    }
2274
2275    #[test]
2276    fn ung_qualifiers_empty_when_absent() {
2277        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2278                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
2279                      UNH+1+ORDERS:D:96A:UN'\
2280                      BGM+220+PO-001+9'\
2281                      UNT+3+1'\
2282                      UNE+1+1'\
2283                      UNZ+1+1'";
2284        let r = parse_and_validate(input).unwrap();
2285        let g = &r.functional_groups[0];
2286        assert_eq!(g.app_sender_qualifier, "");
2287        assert_eq!(g.app_recipient_qualifier, "");
2288    }
2289
2290    // ── LenientResult methods ─────────────────────────────────────────────────
2291
2292    #[test]
2293    fn lenient_result_is_valid_true_on_clean_interchange() {
2294        let owned = parse(VALID_INTERCHANGE);
2295        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2296        let r = validate_envelope_lenient(&segs);
2297        assert!(r.is_valid());
2298        assert!(r.errors.is_empty());
2299        assert!(r.interchange.is_some());
2300    }
2301
2302    #[test]
2303    fn lenient_result_into_strict_ok_path() {
2304        let owned = parse(VALID_INTERCHANGE);
2305        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2306        let r = validate_envelope_lenient(&segs);
2307        let strict = r.into_strict();
2308        assert!(
2309            strict.is_ok(),
2310            "into_strict() should succeed for valid interchange"
2311        );
2312        assert_eq!(strict.unwrap().messages.len(), 1);
2313    }
2314
2315    #[test]
2316    fn lenient_result_into_strict_err_path() {
2317        // Count mismatch → into_strict() returns Err with the error
2318        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'";
2319        let owned = parse(input);
2320        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2321        let r = validate_envelope_lenient(&segs);
2322        assert!(!r.is_valid());
2323        let strict = r.into_strict();
2324        assert!(strict.is_err());
2325        let errors = strict.unwrap_err();
2326        assert_eq!(errors.len(), 1);
2327        assert!(matches!(
2328            &errors[0],
2329            EdifactError::MessageCountMismatch {
2330                expected: 2,
2331                actual: 1
2332            }
2333        ));
2334    }
2335
2336    // ── Direct parse_unh / parse_ung API ─────────────────────────────────────
2337
2338    #[test]
2339    fn parse_unh_direct_extracts_all_s009_fields() {
2340        // parse_unh is called internally by extract_messages_flat; all S009 fields
2341        // it extracts surface in the resulting MessageEnvelope.  We verify them here
2342        // rather than calling parse_unh(&seg) from a Vec<Segment<'_>>, which would
2343        // conflict with the SmallVec-based Element drop-check (see API docs).
2344        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2345                      UNH+REF99+ORDERS:D:96A:UN:EAN010'\
2346                      BGM+220+PO-001+9'\
2347                      UNT+3+REF99'\
2348                      UNZ+1+1'";
2349        let r = parse_and_validate(input).expect("must parse ok");
2350        let msg = &r.messages[0];
2351        assert_eq!(msg.message_ref, "REF99");
2352        assert_eq!(msg.message_type, "ORDERS");
2353        assert_eq!(msg.version, "D");
2354        assert_eq!(msg.release, "96A");
2355        assert_eq!(msg.controlling_agency, "UN");
2356        assert_eq!(msg.association_code, "EAN010");
2357    }
2358
2359    #[test]
2360    fn parse_ung_direct_extracts_identifier_fields() {
2361        // parse_ung fields surface via the FunctionalGroupEnvelope returned by validation.
2362        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2363                      UNG+ORDERS+APPSEND:ZZZ+APPRECV:14+200101:0900+GRP01+UN+D:96A'\
2364                      UNH+1+ORDERS:D:96A:UN'\
2365                      BGM+220+PO-001+9'\
2366                      UNT+3+1'\
2367                      UNE+1+GRP01'\
2368                      UNZ+1+1'";
2369        let r = parse_and_validate(input).expect("must parse ok");
2370        let g = &r.functional_groups[0];
2371        assert_eq!(g.group_id, "ORDERS");
2372        assert_eq!(g.app_sender, "APPSEND");
2373        assert_eq!(g.app_sender_qualifier, "ZZZ");
2374        assert_eq!(g.app_recipient, "APPRECV");
2375        assert_eq!(g.app_recipient_qualifier, "14");
2376        assert_eq!(g.group_ref, "GRP01");
2377        assert_eq!(g.controlling_agency, "UN");
2378        assert_eq!(g.version, "D");
2379        assert_eq!(g.release, "96A");
2380    }
2381
2382    // ── find_message convenience method ──────────────────────────────────────
2383
2384    #[test]
2385    fn find_message_returns_correct_message_by_ref() {
2386        let input = b"UNB+UNOA:3+S+R+200101:0900+CTRL2'\
2387                      UNH+REF-A+ORDERS:D:96A:UN'\
2388                      BGM+220+PO-001+9'\
2389                      UNT+3+REF-A'\
2390                      UNH+REF-B+INVOIC:D:96A:UN'\
2391                      BGM+380+INV-001+9'\
2392                      UNT+3+REF-B'\
2393                      UNZ+2+CTRL2'";
2394        let r = parse_and_validate(input).expect("two-message interchange must parse ok");
2395        let msg_a = r.find_message("REF-A");
2396        assert!(msg_a.is_some());
2397        assert_eq!(msg_a.unwrap().message_type, "ORDERS");
2398        let msg_b = r.find_message("REF-B");
2399        assert!(msg_b.is_some());
2400        assert_eq!(msg_b.unwrap().message_type, "INVOIC");
2401        assert!(r.find_message("MISSING").is_none());
2402    }
2403
2404    // ── UNG missing mandatory group_ref ──────────────────────────────────────
2405
2406    #[test]
2407    fn ung_missing_group_ref_returns_err() {
2408        // UNG with element [4] (group ref) empty — must error, not silently use ""
2409        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2410                      UNG+ORDERS+S+R+200101:0900++UN+D:96A'\
2411                      UNH+1+ORDERS:D:96A:UN'\
2412                      BGM+220+PO-001+9'\
2413                      UNT+3+1'\
2414                      UNE+1+'\
2415                      UNZ+1+1'";
2416        let result = parse_and_validate(input);
2417        assert!(
2418            matches!(result,
2419                Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 4, component_index: 0 })
2420                if tag == "UNG"
2421            ),
2422            "expected MissingRequiredComponent for empty UNG group_ref, got {result:?}"
2423        );
2424    }
2425
2426    // ── Edge case and ergonomics tests ────────────────────────────────────────
2427
2428    #[test]
2429    fn empty_segment_list_returns_missing_unb() {
2430        // Contract: validate_envelope(&[]) must return MissingSegment{UNB},
2431        // not panic or return Ok.
2432        let result = validate_envelope(&[]);
2433        assert!(
2434            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNB"),
2435            "expected MissingSegment(UNB) for empty input, got {result:?}"
2436        );
2437    }
2438
2439    #[test]
2440    fn single_segment_only_unb_returns_missing_unz() {
2441        // Only UNB, no UNZ — should fail with MissingSegment{UNZ}.
2442        let input = b"UNB+UNOA:3+S+R+200101:0900+1'";
2443        let owned = parse(input);
2444        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2445        let result = validate_envelope(&segs);
2446        assert!(
2447            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNZ"),
2448            "expected MissingSegment(UNZ) for UNB-only input, got {result:?}"
2449        );
2450    }
2451
2452    #[test]
2453    fn display_validated_interchange_includes_count() {
2454        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2455        let s = r.to_string();
2456        // Must include the numeric count, not just the label
2457        assert!(
2458            s.contains("messages=1"),
2459            "Display must include count '1': {s}"
2460        );
2461    }
2462
2463    #[test]
2464    fn display_interchange_envelope_contains_arrow() {
2465        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2466        let s = r.interchange.to_string();
2467        // Must use ASCII arrow, not Unicode →
2468        assert!(s.contains("->"), "Display must use ASCII '->' arrow: {s}");
2469        assert!(
2470            !s.contains('\u{2192}'),
2471            "Display must not use Unicode → arrow: {s}"
2472        );
2473    }
2474
2475    #[test]
2476    fn display_functional_group_envelope() {
2477        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2478                      UNG+ORDERS+APPSEND+APPRECV+200101:0900+GRP01+UN+D:96A'\
2479                      UNH+1+ORDERS:D:96A:UN'\
2480                      BGM+220+PO-001+9'\
2481                      UNT+3+1'\
2482                      UNE+1+GRP01'\
2483                      UNZ+1+1'";
2484        let r = parse_and_validate(input).expect("must parse ok");
2485        let g = &r.functional_groups[0];
2486        let s = g.to_string();
2487        assert!(s.contains("ORDERS"), "Display must include group_id: {s}");
2488        assert!(
2489            s.contains("APPSEND"),
2490            "Display must include app_sender: {s}"
2491        );
2492        assert!(
2493            s.contains("APPRECV"),
2494            "Display must include app_recipient: {s}"
2495        );
2496        assert!(s.contains("GRP01"), "Display must include group_ref: {s}");
2497        assert!(
2498            s.contains("msgs="),
2499            "Display must include message count label: {s}"
2500        );
2501        assert!(
2502            s.contains("1/1"),
2503            "Display must include actual/declared counts: {s}"
2504        );
2505    }
2506
2507    #[test]
2508    fn lenient_has_errors_is_inverse_of_is_valid() {
2509        let owned = parse(VALID_INTERCHANGE);
2510        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2511        let valid = validate_envelope_lenient(&segs);
2512        assert!(valid.is_valid());
2513        assert!(!valid.has_errors());
2514
2515        // Count mismatch: is_valid() == false, has_errors() == true
2516        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'";
2517        let owned2 = parse(input);
2518        let segs2: Vec<Segment<'_>> = owned2
2519            .iter()
2520            .map(crate::OwnedSegment::as_borrowed)
2521            .collect();
2522        let invalid = validate_envelope_lenient(&segs2);
2523        assert!(!invalid.is_valid());
2524        assert!(invalid.has_errors());
2525    }
2526}