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