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