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