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