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    #[must_use]
386    pub fn iter_messages(&self) -> impl Iterator<Item = &MessageEnvelope> {
387        self.messages.iter()
388    }
389
390    /// Find the first message whose `message_ref` equals `reference`.
391    ///
392    /// Useful for locating a specific message in an interchange with multiple
393    /// messages after calling `validate_envelope`.
394    ///
395    /// Returns `None` if no message with that reference exists.
396    #[inline]
397    #[must_use]
398    pub fn find_message(&self, reference: &str) -> Option<&MessageEnvelope> {
399        self.messages.iter().find(|m| m.message_ref == reference)
400    }
401
402    /// Collect all messages of a given type (e.g. `"ORDERS"`, `"INVOIC"`).
403    ///
404    /// Returns a `Vec` of references to matching messages in document order.
405    /// Returns an empty `Vec` when the interchange contains no messages of
406    /// the requested type.
407    ///
408    /// Prefer [`iter_messages_by_type`](Self::iter_messages_by_type) in tight loops
409    /// to avoid the allocation.
410    #[must_use]
411    pub fn messages_by_type(&self, message_type: &str) -> Vec<&MessageEnvelope> {
412        self.messages
413            .iter()
414            .filter(|m| m.message_type == message_type)
415            .collect()
416    }
417
418    /// Iterate over all messages of a given type without allocating.
419    ///
420    /// Zero-allocation alternative to [`messages_by_type`](Self::messages_by_type).
421    ///
422    /// The bound `'q: 's` means the `message_type` string reference must outlive the
423    /// borrow of `self`.  In practice this is always satisfied when passing a string
424    /// literal (`&'static str`) or any string whose lifetime is at least as long as
425    /// the `ValidatedInterchange` reference.  For short-lived computed strings, use
426    /// [`messages_by_type`](Self::messages_by_type) which collects eagerly and releases the string reference
427    /// immediately.
428    #[inline]
429    #[must_use]
430    pub fn iter_messages_by_type<'s, 'q: 's>(
431        &'s self,
432        message_type: &'q str,
433    ) -> impl Iterator<Item = &'s MessageEnvelope> + 's {
434        self.messages
435            .iter()
436            .filter(move |m| m.message_type == message_type)
437    }
438}
439
440impl std::fmt::Display for ValidatedInterchange {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        write!(
443            f,
444            "{ic} messages={n}",
445            ic = self.interchange,
446            n = self.messages.len(),
447        )
448    }
449}
450
451impl std::fmt::Display for FunctionalGroupEnvelope {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        write!(
454            f,
455            "{gid} sender={sender} recipient={recip} [{gref}] ({agency}) msgs={actual}/{declared}",
456            gid = self.group_id,
457            sender = self.app_sender,
458            recip = self.app_recipient,
459            gref = self.group_ref,
460            agency = self.controlling_agency,
461            actual = self.actual_message_count,
462            declared = self.declared_message_count,
463        )
464    }
465}
466
467/// Parsed identifier fields from a `UNH` segment.
468///
469/// All string slices borrow from the input bytes so they live as long as the
470/// original byte buffer.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
472#[non_exhaustive]
473#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
474pub struct MessageIdentifier<'a> {
475    /// Message reference number (UNH DE 0062, element index 0).
476    pub message_ref: &'a str,
477    pub message_type: &'a str,
478    pub version: &'a str,
479    pub release: &'a str,
480    pub controlling_agency: &'a str,
481    /// Association assigned code (UNH S009 DE 0057).
482    ///
483    /// Matches `MessageEnvelope::association_code` for the same message.
484    pub association_code: &'a str,
485}
486
487// ── Public API ────────────────────────────────────────────────────────────────
488
489/// Extract identifier fields from a `UNH` segment (zero allocation).
490pub fn parse_unh<'a>(unh: &'a Segment<'a>) -> Result<MessageIdentifier<'a>, EdifactError> {
491    // Element [0]: DE 0062 — message reference number (required, simple DE)
492    let message_ref = unh
493        .get_element(0)
494        .and_then(|e| e.get_component(0))
495        .filter(|s| !s.is_empty())
496        .ok_or_else(|| EdifactError::MissingRequiredComponent {
497            tag: "UNH".to_owned(),
498            element_index: 0,
499            component_index: 0,
500        })?;
501    // Element [1]: S009 composite — message type, version, release, agency, association
502    let elem = unh
503        .get_element(1)
504        .ok_or_else(|| EdifactError::MissingRequiredElement {
505            tag: "UNH".to_owned(),
506            element_index: 1,
507        })?;
508    let message_type =
509        elem.get_component(0)
510            .ok_or_else(|| EdifactError::MissingRequiredComponent {
511                tag: "UNH".to_owned(),
512                element_index: 1,
513                component_index: 0,
514            })?;
515    Ok(MessageIdentifier {
516        message_ref,
517        message_type,
518        version: elem.get_component(1).unwrap_or(""),
519        release: elem.get_component(2).unwrap_or(""),
520        controlling_agency: elem.get_component(3).unwrap_or(""),
521        association_code: elem.get_component(4).unwrap_or(""),
522    })
523}
524
525/// Parsed identifier fields from a `UNG` segment.
526///
527/// All string slices borrow from the input bytes so they live as long as the
528/// original byte buffer.  Use this for zero-allocation group routing in streaming
529/// scenarios where you need to inspect group identity without full validation.
530///
531/// # UNG element positions (ISO 9735-1 §8, 0-indexed)
532///
533/// ```text
534/// [0] DE 0038  functional group identification
535/// [1] S006     application sender id + qualifier (comp 0 / comp 1)
536/// [2] S007     application recipient id + qualifier (comp 0 / comp 1)
537/// [3] S004     date + time (comp 0 / comp 1)
538/// [4] DE 0048  group reference number
539/// [5] DE 0051  controlling agency
540/// [6] S008     version + release (comp 0 / comp 1)
541/// ```
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
543#[non_exhaustive]
544#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
545pub struct GroupIdentifier<'a> {
546    /// Functional group identification (UNG DE 0038), e.g. `"ORDERS"`.
547    pub group_id: &'a str,
548    /// Application sender identification (UNG S006 DE 0040).
549    pub app_sender: &'a str,
550    /// Application sender identification code qualifier (UNG S006 DE 0007).
551    pub app_sender_qualifier: &'a str,
552    /// Application recipient identification (UNG S007 DE 0044).
553    pub app_recipient: &'a str,
554    /// Application recipient identification code qualifier (UNG S007 DE 0007).
555    pub app_recipient_qualifier: &'a str,
556    /// Group reference number (UNG DE 0048).
557    pub group_ref: &'a str,
558    /// Controlling agency (UNG DE 0051), e.g. `"UN"`.
559    pub controlling_agency: &'a str,
560    /// Message version number from S008 (UNG DE 0052), e.g. `"D"`.
561    pub version: &'a str,
562    /// Message release number from S008 (UNG DE 0054), e.g. `"96A"`.
563    pub release: &'a str,
564}
565
566/// Extract identifier fields from a `UNG` segment (zero allocation).
567///
568/// The symmetric counterpart to [`parse_unh`] for streaming scenarios that need
569/// to inspect or route functional groups before full validation.
570pub fn parse_ung<'a>(ung: &'a Segment<'a>) -> Result<GroupIdentifier<'a>, EdifactError> {
571    let group_id = ung
572        .get_element(0)
573        .and_then(|e| e.get_component(0))
574        .unwrap_or("");
575    let app_sender = ung
576        .get_element(1)
577        .and_then(|e| e.get_component(0))
578        .unwrap_or("");
579    let app_sender_qualifier = ung
580        .get_element(1)
581        .and_then(|e| e.get_component(1))
582        .unwrap_or("");
583    let app_recipient = ung
584        .get_element(2)
585        .and_then(|e| e.get_component(0))
586        .unwrap_or("");
587    let app_recipient_qualifier = ung
588        .get_element(2)
589        .and_then(|e| e.get_component(1))
590        .unwrap_or("");
591    let group_ref = ung
592        .get_element(4)
593        .and_then(|e| e.get_component(0))
594        .ok_or_else(|| EdifactError::MissingRequiredComponent {
595            tag: "UNG".to_owned(),
596            element_index: 4,
597            component_index: 0,
598        })?;
599    let controlling_agency = ung
600        .get_element(5)
601        .and_then(|e| e.get_component(0))
602        .unwrap_or("");
603    let s008 = ung.get_element(6);
604    let version = s008.as_ref().and_then(|e| e.get_component(0)).unwrap_or("");
605    let release = s008.as_ref().and_then(|e| e.get_component(1)).unwrap_or("");
606    Ok(GroupIdentifier {
607        group_id,
608        app_sender,
609        app_sender_qualifier,
610        app_recipient,
611        app_recipient_qualifier,
612        group_ref,
613        controlling_agency,
614        version,
615        release,
616    })
617}
618
619/// Validate the EDIFACT interchange envelope (fail-fast, borrowed-segment path).
620///
621/// Supports direct-message interchanges and functional-group interchanges
622/// (ISO 9735-1 §8).  Returns [`ValidatedInterchange`] on success.
623pub fn validate_envelope(segments: &[Segment<'_>]) -> Result<ValidatedInterchange, EdifactError> {
624    validate_envelope_impl(segments)
625}
626
627/// Validate the EDIFACT interchange envelope (fail-fast, owned-segment path).
628pub fn validate_envelope_from_owned(
629    segments: &[OwnedSegment],
630) -> Result<ValidatedInterchange, EdifactError> {
631    validate_envelope_impl(segments)
632}
633
634/// Result of a lenient envelope validation — carries both a (possibly partial)
635/// interchange and the full list of collected errors.
636///
637/// Returned by [`validate_envelope_lenient`] and [`validate_envelope_lenient_from_owned`].
638///
639/// # Semantics
640///
641/// | Condition | `interchange` | `errors` |
642/// |-----------|---------------|----------|
643/// | Structurally valid, all counts correct | `Some(result)` | empty |
644/// | Structurally parseable but count violations | `Some(partial)` | non-empty |
645/// | Missing `UNB`/`UNZ`, stray segments, etc. | `None` | non-empty |
646#[derive(Debug)]
647#[non_exhaustive]
648pub struct LenientResult {
649    /// The parsed interchange, if extraction was structurally possible.
650    pub interchange: Option<ValidatedInterchange>,
651    /// All errors collected during validation, in discovery order.
652    pub errors: Vec<EdifactError>,
653}
654
655impl LenientResult {
656    /// Returns `true` if no errors were detected and the interchange is fully valid.
657    #[inline]
658    #[must_use]
659    pub fn is_valid(&self) -> bool {
660        self.errors.is_empty()
661    }
662
663    /// Returns `true` if one or more errors were collected.
664    ///
665    /// The readable inverse of [`is_valid`](Self::is_valid).
666    /// A partial interchange may still be present even when `has_errors()` returns `true`.
667    #[inline]
668    #[must_use]
669    pub fn has_errors(&self) -> bool {
670        !self.errors.is_empty()
671    }
672
673    /// Convert into a `Result`, returning the interchange on success or the errors on failure.
674    ///
675    /// The partial interchange (when present alongside errors) is discarded on the
676    /// `Err` path.  Use the fields directly when you need both simultaneously.
677    #[must_use]
678    pub fn into_strict(self) -> Result<ValidatedInterchange, Vec<EdifactError>> {
679        if self.errors.is_empty() {
680            Ok(self.interchange.expect(
681                "LenientResult: no errors but interchange is None — this is a bug in edifact-rs",
682            ))
683        } else {
684            Err(self.errors)
685        }
686    }
687}
688
689/// Validate the EDIFACT envelope and collect **all** errors rather than stopping
690/// at the first failure (borrowed-segment path).
691///
692/// Returns a [`LenientResult`] whose `interchange` field is:
693///
694/// - `Some(result)` with empty `errors` when fully valid.
695/// - `Some(partial)` with non-empty `errors` when only count violations were found —
696///   lets diagnostic tooling display the actual interchange structure.
697/// - `None` when the interchange is structurally broken beyond recovery
698///   (missing `UNB`/`UNZ`, stray segments, etc.).
699pub fn validate_envelope_lenient(segments: &[Segment<'_>]) -> LenientResult {
700    validate_envelope_lenient_impl(segments)
701}
702
703/// Lenient validation over an owned-segment slice — collects all errors.
704///
705/// See [`validate_envelope_lenient`] for full semantics.
706pub fn validate_envelope_lenient_from_owned(segments: &[OwnedSegment]) -> LenientResult {
707    validate_envelope_lenient_impl(segments)
708}
709
710// ── Core implementation ───────────────────────────────────────────────────────
711
712fn validate_envelope_impl<S: SegmentReader>(
713    segments: &[S],
714) -> Result<ValidatedInterchange, EdifactError> {
715    let mut interchange_env = extract_interchange(segments)?;
716
717    let inner = if segments.len() >= 2 {
718        &segments[1..segments.len() - 1]
719    } else {
720        &[]
721    };
722
723    let (functional_groups, messages) = extract_content(inner)?;
724
725    // UNZ unit count semantics (ISO 9735-1 §9.2):
726    //   with groups    → counts groups
727    //   without groups → counts messages
728    let actual_unit_count = if functional_groups.is_empty() {
729        messages.len()
730    } else {
731        functional_groups.len()
732    };
733    interchange_env.actual_unit_count =
734        u32::try_from(actual_unit_count).map_err(|_| EdifactError::InterchangeTooLarge {
735            count: actual_unit_count as u64,
736        })?;
737
738    if interchange_env.declared_unit_count != interchange_env.actual_unit_count {
739        return Err(EdifactError::MessageCountMismatch {
740            expected: interchange_env.declared_unit_count,
741            actual: interchange_env.actual_unit_count,
742        });
743    }
744
745    for msg in &messages {
746        if msg.declared_segment_count != msg.actual_segment_count {
747            return Err(EdifactError::SegmentCountMismatch {
748                expected: msg.declared_segment_count,
749                actual: msg.actual_segment_count,
750                message_ref: msg.message_ref.clone(),
751            });
752        }
753    }
754
755    Ok(ValidatedInterchange {
756        interchange: interchange_env,
757        functional_groups,
758        messages,
759    })
760}
761
762fn validate_envelope_lenient_impl<S: SegmentReader>(segments: &[S]) -> LenientResult {
763    // Fast path: if strict validation passes there is nothing more to do.
764    match validate_envelope_impl(segments) {
765        Ok(result) => {
766            return LenientResult {
767                interchange: Some(result),
768                errors: Vec::new(),
769            };
770        }
771        Err(_) => {}
772    }
773
774    // Structural extraction: if UNB/UNZ are missing or unreadable we cannot
775    // build any useful result.
776    let mut ie = match extract_interchange(segments) {
777        Ok(ie) => ie,
778        Err(e) => {
779            return LenientResult {
780                interchange: None,
781                errors: vec![e],
782            };
783        }
784    };
785
786    let inner = if segments.len() >= 2 {
787        &segments[1..segments.len() - 1]
788    } else {
789        &[]
790    };
791
792    // Content extraction: structural errors (missing UNH/UNT, stray segments,
793    // etc.) mean we cannot build a usable interchange either.
794    let (functional_groups, messages) = match extract_content(inner) {
795        Ok(pair) => pair,
796        Err(e) => {
797            return LenientResult {
798                interchange: None,
799                errors: vec![e],
800            };
801        }
802    };
803
804    // From here we have a parseable structure.  Collect count violations as
805    // errors but still return the (partial) interchange so that diagnostic
806    // tooling can display what was actually found.
807    let mut errors: Vec<EdifactError> = Vec::new();
808
809    let actual_unit_count = if functional_groups.is_empty() {
810        messages.len()
811    } else {
812        functional_groups.len()
813    };
814    ie.actual_unit_count = u32::try_from(actual_unit_count).unwrap_or(u32::MAX);
815
816    if ie.declared_unit_count != ie.actual_unit_count {
817        errors.push(EdifactError::MessageCountMismatch {
818            expected: ie.declared_unit_count,
819            actual: ie.actual_unit_count,
820        });
821    }
822
823    for msg in &messages {
824        if msg.declared_segment_count != msg.actual_segment_count {
825            errors.push(EdifactError::SegmentCountMismatch {
826                expected: msg.declared_segment_count,
827                actual: msg.actual_segment_count,
828                message_ref: msg.message_ref.clone(),
829            });
830        }
831    }
832
833    let result = ValidatedInterchange {
834        interchange: ie,
835        functional_groups,
836        messages,
837    };
838    LenientResult {
839        interchange: Some(result),
840        errors,
841    }
842}
843
844// ── Interchange extraction ────────────────────────────────────────────────────
845
846fn extract_interchange<S: SegmentReader>(
847    segments: &[S],
848) -> Result<InterchangeEnvelope, EdifactError> {
849    if segments.first().map(|s| s.tag()) != Some("UNB") {
850        return Err(EdifactError::MissingSegment {
851            tag: "UNB".to_owned(),
852            expected_position: "first segment of interchange".to_owned(),
853        });
854    }
855    if segments.last().map(|s| s.tag()) != Some("UNZ") {
856        return Err(EdifactError::MissingSegment {
857            tag: "UNZ".to_owned(),
858            expected_position: "last segment of interchange".to_owned(),
859        });
860    }
861
862    let unb = &segments[0];
863    let unz = &segments[segments.len() - 1];
864
865    let syntax_identifier = unb.required_component_field(0, 0)?.to_owned();
866    let syntax_version = unb.component(0, 1).unwrap_or("").to_owned();
867
868    // Validate DE 0001 against the ISO 9735-1 §3.1 list of defined syntax identifiers.
869    const VALID_SYNTAX_IDS: &[&str] = &["UNOA", "UNOB", "UNOC", "UNOD", "UNOE", "UNOF", "KECA"];
870    if !VALID_SYNTAX_IDS.contains(&syntax_identifier.as_str()) {
871        return Err(EdifactError::UnrecognisedSyntaxIdentifier(
872            syntax_identifier,
873        ));
874    }
875
876    let sender_id = unb.required_component_field(1, 0)?.to_owned();
877    let sender_qualifier = unb.component(1, 1).unwrap_or("").to_owned();
878    // UNB S002 comp[2]: DE 0014 — sender routing address
879    let sender_routing_address = unb
880        .component(1, 2)
881        .filter(|s| !s.is_empty())
882        .map(str::to_owned);
883
884    let recipient_id = unb.required_component_field(2, 0)?.to_owned();
885    let recipient_qualifier = unb.component(2, 1).unwrap_or("").to_owned();
886    // UNB S003 comp[2]: DE 0014 — recipient routing address
887    let recipient_routing_address = unb
888        .component(2, 2)
889        .filter(|s| !s.is_empty())
890        .map(str::to_owned);
891
892    let date = unb.required_component_field(3, 0)?.to_owned();
893    let time_raw = unb.component(3, 1).unwrap_or("");
894    let time = if time_raw.is_empty() {
895        None
896    } else {
897        Some(time_raw.to_owned())
898    };
899
900    let control_ref = unb.required_component_field(4, 0)?.to_owned();
901
902    // UNB element [5]: S005 — recipient's reference/password (DE 0022, comp 0) + qualifier (DE 0025, comp 1)
903    let recipient_password = unb
904        .component(5, 0)
905        .filter(|s| !s.is_empty())
906        .map(str::to_owned);
907    let recipient_password_qualifier = unb
908        .component(5, 1)
909        .filter(|s| !s.is_empty())
910        .map(str::to_owned);
911    // UNB element [6]: DE 0026 — application reference
912    let app_ref = unb
913        .component(6, 0)
914        .filter(|s| !s.is_empty())
915        .map(str::to_owned);
916    // UNB element [7]: DE 0029 — processing priority code
917    let processing_priority = unb
918        .component(7, 0)
919        .filter(|s| !s.is_empty())
920        .map(str::to_owned);
921    // UNB element [8]: DE 0031 — acknowledgement request ("1" = requested)
922    let acknowledgement_request = unb.component(8, 0).map_or(false, |v| v == "1");
923    // UNB element [9]: DE 0032 — communications agreement ID
924    let communications_agreement_id = unb
925        .component(9, 0)
926        .filter(|s| !s.is_empty())
927        .map(str::to_owned);
928    // UNB element [10]: DE 0035 — test indicator ("1" = test)
929    let test_indicator = unb.component(10, 0).map_or(false, |v| v == "1");
930
931    let unz_control_ref = unz.required_component_field(1, 0)?;
932    if unz_control_ref != control_ref {
933        return Err(EdifactError::QualifierMismatch {
934            tag: "UNZ".to_owned(),
935            actual: unz_control_ref.to_owned(),
936            expected: control_ref,
937            offset: unz.span_start(),
938        });
939    }
940
941    let declared_unit_count: u32 =
942        unz.required_component_field(0, 0)?
943            .parse()
944            .map_err(|_| EdifactError::InvalidText {
945                offset: unz.span_start(),
946            })?;
947
948    Ok(InterchangeEnvelope {
949        syntax_identifier,
950        syntax_version,
951        sender_id,
952        sender_qualifier,
953        sender_routing_address,
954        recipient_id,
955        recipient_qualifier,
956        recipient_routing_address,
957        date,
958        time,
959        control_ref,
960        recipient_password,
961        recipient_password_qualifier,
962        app_ref,
963        processing_priority,
964        acknowledgement_request,
965        communications_agreement_id,
966        test_indicator,
967        declared_unit_count,
968        actual_unit_count: 0,
969    })
970}
971
972// ── Content extraction ────────────────────────────────────────────────────────
973
974fn extract_content<S: SegmentReader>(
975    inner: &[S],
976) -> Result<(Vec<FunctionalGroupEnvelope>, Vec<MessageEnvelope>), EdifactError> {
977    // A UNG as the first inner segment means the interchange uses functional groups.
978    // Checking only the first tag is O(1) and correct: if UNG is present it must
979    // always be first; a stray UNE without a preceding UNG is caught downstream.
980    if inner.first().map_or(false, |s| s.tag() == "UNG") {
981        let groups = extract_with_groups(inner)?;
982        let messages = groups
983            .iter()
984            .flat_map(|g| g.messages.iter().cloned())
985            .collect();
986        Ok((groups, messages))
987    } else {
988        let messages = extract_messages_flat(inner)?;
989        Ok((vec![], messages))
990    }
991}
992
993/// Find the index of the `UNE` that closes the `UNG` opened just before `start`.
994fn find_matching_une<S: SegmentReader>(
995    segments: &[S],
996    start: usize,
997) -> Result<usize, EdifactError> {
998    for i in start..segments.len() {
999        match segments[i].tag() {
1000            "UNE" => return Ok(i),
1001            "UNG" => {
1002                return Err(EdifactError::InvalidSegmentForMessage {
1003                    tag: "UNG".to_owned(),
1004                    message_type: "ENVELOPE".to_owned(),
1005                    offset: segments[i].span_start(),
1006                });
1007            }
1008            _ => {}
1009        }
1010    }
1011    Err(EdifactError::MissingSegment {
1012        tag: "UNE".to_owned(),
1013        expected_position: "end of functional group".to_owned(),
1014    })
1015}
1016
1017fn extract_with_groups<S: SegmentReader>(
1018    inner: &[S],
1019) -> Result<Vec<FunctionalGroupEnvelope>, EdifactError> {
1020    let mut groups: Vec<FunctionalGroupEnvelope> = Vec::new();
1021    let mut i = 0;
1022
1023    while i < inner.len() {
1024        let seg = &inner[i];
1025        match seg.tag() {
1026            "UNG" => {
1027                let ung_idx = i;
1028                let une_idx = find_matching_une(inner, ung_idx + 1)?;
1029
1030                let ung = &inner[ung_idx];
1031                let group_id = ung.component(0, 0).unwrap_or("").to_owned();
1032                let app_sender = ung.component(1, 0).unwrap_or("").to_owned();
1033                let app_sender_qualifier = ung.component(1, 1).unwrap_or("").to_owned();
1034                let app_recipient = ung.component(2, 0).unwrap_or("").to_owned();
1035                let app_recipient_qualifier = ung.component(2, 1).unwrap_or("").to_owned();
1036                let date = ung.component(3, 0).unwrap_or("").to_owned();
1037                let time_raw = ung.component(3, 1).unwrap_or("");
1038                let time = if time_raw.is_empty() {
1039                    None
1040                } else {
1041                    Some(time_raw.to_owned())
1042                };
1043                // UNG DE 0048 — group reference number (mandatory per ISO 9735-1 §8)
1044                let group_ref = ung.required_component_field(4, 0)?.to_owned();
1045                let controlling_agency = ung.component(5, 0).unwrap_or("").to_owned();
1046                // UNG S008 — version (DE 0052, comp 0) + release (DE 0054, comp 1)
1047                // S008 is always at element index [6]; there is no element [7] in ISO 9735-1 §8.
1048                let version = ung.component(6, 0).unwrap_or("").to_owned();
1049                let release = ung.component(6, 1).unwrap_or("").to_owned();
1050
1051                let une = &inner[une_idx];
1052                let declared_str = une.required_component_field(0, 0)?;
1053                let declared_message_count: u32 =
1054                    declared_str
1055                        .parse()
1056                        .map_err(|_| EdifactError::InvalidText {
1057                            offset: une.span_start(),
1058                        })?;
1059                let une_ref = une.required_component_field(1, 0)?;
1060                if une_ref != group_ref {
1061                    return Err(EdifactError::QualifierMismatch {
1062                        tag: "UNE".to_owned(),
1063                        actual: une_ref.to_owned(),
1064                        expected: group_ref.clone(),
1065                        offset: une.span_start(),
1066                    });
1067                }
1068
1069                let group_content = &inner[ung_idx + 1..une_idx];
1070                let messages = extract_messages_flat(group_content)?;
1071                let actual_message_count = u32::try_from(messages.len()).map_err(|_| {
1072                    EdifactError::InterchangeTooLarge {
1073                        count: messages.len() as u64,
1074                    }
1075                })?;
1076
1077                if declared_message_count != actual_message_count {
1078                    return Err(EdifactError::MessageCountMismatch {
1079                        expected: declared_message_count,
1080                        actual: actual_message_count,
1081                    });
1082                }
1083
1084                groups.push(FunctionalGroupEnvelope {
1085                    group_id,
1086                    app_sender,
1087                    app_sender_qualifier,
1088                    app_recipient,
1089                    app_recipient_qualifier,
1090                    date,
1091                    time,
1092                    group_ref,
1093                    controlling_agency,
1094                    version,
1095                    release,
1096                    declared_message_count,
1097                    actual_message_count,
1098                    messages,
1099                });
1100                i = une_idx + 1;
1101            }
1102            "UNE" => {
1103                return Err(EdifactError::InvalidSegmentForMessage {
1104                    tag: "UNE".to_owned(),
1105                    message_type: "ENVELOPE".to_owned(),
1106                    offset: seg.span_start(),
1107                });
1108            }
1109            "UNH" => {
1110                // Mixing direct messages with functional groups is invalid.
1111                return Err(EdifactError::InvalidSegmentForMessage {
1112                    tag: "UNH".to_owned(),
1113                    message_type: "ENVELOPE".to_owned(),
1114                    offset: seg.span_start(),
1115                });
1116            }
1117            _ => {
1118                return Err(EdifactError::InvalidSegmentForMessage {
1119                    tag: seg.tag().to_owned(),
1120                    message_type: "ENVELOPE".to_owned(),
1121                    offset: seg.span_start(),
1122                });
1123            }
1124        }
1125    }
1126
1127    Ok(groups)
1128}
1129
1130/// Extract `UNH`/`UNT` message pairs from a flat slice (no UNB/UNZ/UNG/UNE expected).
1131fn extract_messages_flat<S: SegmentReader>(
1132    segments: &[S],
1133) -> Result<Vec<MessageEnvelope>, EdifactError> {
1134    let mut messages: Vec<MessageEnvelope> = Vec::new();
1135    let mut in_message = false;
1136    let mut msg_start_idx: usize = 0;
1137    let mut unh_idx: Option<usize> = None;
1138
1139    for (i, seg) in segments.iter().enumerate() {
1140        match seg.tag() {
1141            "UNH" => {
1142                if in_message {
1143                    return Err(EdifactError::InvalidSegmentForMessage {
1144                        tag: "UNH".to_owned(),
1145                        message_type: "ENVELOPE".to_owned(),
1146                        offset: seg.span_start(),
1147                    });
1148                }
1149                in_message = true;
1150                msg_start_idx = i;
1151                unh_idx = Some(i);
1152            }
1153            "UNT" if in_message => {
1154                let u_idx = unh_idx.take().unwrap();
1155                let unh = &segments[u_idx];
1156
1157                let message_ref = unh.required_component_field(0, 0)?.to_owned();
1158                let message_type = unh.required_component_field(1, 0)?.to_owned();
1159                let version = unh.required_component_field(1, 1)?.to_owned();
1160                let release = unh.required_component_field(1, 2)?.to_owned();
1161                let controlling_agency = unh.required_component_field(1, 3)?.to_owned();
1162                let association_code = unh.component(1, 4).unwrap_or("").to_owned();
1163                // UNH element [2]: DE 0068 — common access reference (optional)
1164                let common_access_ref = unh
1165                    .component(2, 0)
1166                    .filter(|s| !s.is_empty())
1167                    .map(str::to_owned);
1168                // UNH element [3]: S010 composite — sequence of transfers (optional)
1169                // comp[0] = DE 0070 (sequence number), comp[1] = DE 0073 (position indicator)
1170                let sequence_of_transfers = unh
1171                    .component(3, 0)
1172                    .filter(|s| !s.is_empty())
1173                    .and_then(|s| s.parse::<u32>().ok());
1174                let transfer_position = unh
1175                    .component(3, 1)
1176                    .filter(|s| !s.is_empty())
1177                    .map(str::to_owned);
1178
1179                let declared_segment_count: u32 = seg
1180                    .required_component_field(0, 0)?
1181                    .parse()
1182                    .map_err(|_| EdifactError::InvalidText {
1183                        offset: seg.span_start(),
1184                    })?;
1185                let unt_ref = seg.required_component_field(1, 0)?;
1186                if unt_ref != message_ref {
1187                    return Err(EdifactError::QualifierMismatch {
1188                        tag: "UNT".to_owned(),
1189                        actual: unt_ref.to_owned(),
1190                        expected: message_ref.clone(),
1191                        offset: seg.span_start(),
1192                    });
1193                }
1194
1195                let actual_segment_count = u32::try_from(i - msg_start_idx + 1).map_err(|_| {
1196                    EdifactError::InterchangeTooLarge {
1197                        count: u64::try_from(i - msg_start_idx + 1).unwrap_or(u64::MAX),
1198                    }
1199                })?;
1200
1201                in_message = false;
1202                messages.push(MessageEnvelope {
1203                    message_ref,
1204                    message_type,
1205                    version,
1206                    release,
1207                    controlling_agency,
1208                    association_code,
1209                    common_access_ref,
1210                    sequence_of_transfers,
1211                    transfer_position,
1212                    declared_segment_count,
1213                    actual_segment_count,
1214                });
1215            }
1216            "UNT" => {
1217                return Err(EdifactError::InvalidSegmentForMessage {
1218                    tag: "UNT".to_owned(),
1219                    message_type: "ENVELOPE".to_owned(),
1220                    offset: seg.span_start(),
1221                });
1222            }
1223            "UNB" | "UNZ" | "UNG" | "UNE" if in_message => {
1224                return Err(EdifactError::InvalidSegmentForMessage {
1225                    tag: seg.tag().to_owned(),
1226                    message_type: "ENVELOPE".to_owned(),
1227                    offset: seg.span_start(),
1228                });
1229            }
1230            _ if !in_message => {
1231                return Err(EdifactError::InvalidSegmentForMessage {
1232                    tag: seg.tag().to_owned(),
1233                    message_type: "ENVELOPE".to_owned(),
1234                    offset: seg.span_start(),
1235                });
1236            }
1237            _ => {}
1238        }
1239    }
1240
1241    if in_message {
1242        return Err(EdifactError::MissingSegment {
1243            tag: "UNT".to_owned(),
1244            expected_position: "end of message group".to_owned(),
1245        });
1246    }
1247
1248    Ok(messages)
1249}
1250
1251// ── Tests ─────────────────────────────────────────────────────────────────────
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256
1257    fn parse(input: &[u8]) -> Vec<crate::OwnedSegment> {
1258        crate::from_reader_collect(std::io::Cursor::new(input)).expect("parse failed")
1259    }
1260
1261    fn parse_and_validate(input: &[u8]) -> Result<ValidatedInterchange, EdifactError> {
1262        let owned = parse(input);
1263        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1264        validate_envelope(&segs)
1265    }
1266
1267    fn parse_and_validate_owned(input: &[u8]) -> Result<ValidatedInterchange, EdifactError> {
1268        validate_envelope_from_owned(&parse(input))
1269    }
1270
1271    const VALID_INTERCHANGE: &[u8] =
1272        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'";
1273
1274    #[test]
1275    fn valid_envelope_parses_ok() {
1276        let result = parse_and_validate(VALID_INTERCHANGE).expect("envelope should be valid");
1277        assert_eq!(result.interchange.sender_id, "SENDER");
1278        assert_eq!(result.interchange.sender_qualifier, ""); // no qualifier in fixture
1279        assert_eq!(result.interchange.recipient_id, "RECEIVER");
1280        assert_eq!(result.interchange.recipient_qualifier, "");
1281        assert_eq!(result.interchange.syntax_identifier, "UNOA");
1282        assert_eq!(result.interchange.syntax_version, "3");
1283        assert_eq!(result.interchange.control_ref, "00001");
1284        assert_eq!(result.interchange.declared_unit_count, 1);
1285        assert_eq!(result.interchange.actual_unit_count, 1);
1286        assert!(!result.interchange.is_test());
1287        assert!(!result.has_functional_groups());
1288        assert_eq!(result.message_count(), 1);
1289        assert_eq!(result.messages[0].message_type, "ORDERS");
1290        assert_eq!(result.messages[0].association_code, "EAN010");
1291        assert_eq!(result.messages[0].declared_segment_count, 4);
1292        assert_eq!(result.messages[0].actual_segment_count, 4);
1293    }
1294
1295    #[test]
1296    fn valid_envelope_parses_ok_owned_path() {
1297        let result = parse_and_validate_owned(VALID_INTERCHANGE).expect("envelope should be valid");
1298        assert_eq!(result.interchange.sender_id, "SENDER");
1299        assert_eq!(result.interchange.actual_unit_count, 1);
1300        assert_eq!(result.messages[0].declared_segment_count, 4);
1301    }
1302
1303    #[test]
1304    fn unt_count_mismatch_returns_err() {
1305        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'";
1306        let result = parse_and_validate(input);
1307        assert!(
1308            matches!(
1309                result,
1310                Err(EdifactError::SegmentCountMismatch { expected: 99, .. })
1311            ),
1312            "expected SegmentCountMismatch, got {result:?}"
1313        );
1314    }
1315
1316    #[test]
1317    fn unz_count_mismatch_returns_err() {
1318        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'";
1319        let result = parse_and_validate(input);
1320        assert!(
1321            matches!(
1322                result,
1323                Err(EdifactError::MessageCountMismatch {
1324                    expected: 2,
1325                    actual: 1
1326                })
1327            ),
1328            "expected MessageCountMismatch(2,1), got {result:?}"
1329        );
1330    }
1331
1332    #[test]
1333    fn missing_unb_returns_err() {
1334        let input = b"UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
1335        assert!(parse_and_validate(input).is_err());
1336    }
1337
1338    #[test]
1339    fn extracts_una_interchange_correctly() {
1340        let result = parse_and_validate(VALID_INTERCHANGE).unwrap();
1341        assert_eq!(result.interchange.syntax_identifier, "UNOA");
1342        assert_eq!(result.interchange.syntax_version, "3");
1343        assert_eq!(result.interchange.date, "230401");
1344        assert_eq!(result.interchange.time.as_deref(), Some("0900"));
1345    }
1346
1347    #[test]
1348    fn sender_and_recipient_qualifiers_extracted() {
1349        // GLN-qualified partners: 1234567890123:14 — qualifier at S002 comp 1
1350        let input = b"UNB+UNOA:3+1234567890123:14+9876543210987:14+200101:0900+1'\
1351                      UNH+1+ORDERS:D:96A:UN'\
1352                      BGM+220+PO-001+9'\
1353                      UNT+3+1'\
1354                      UNZ+1+1'";
1355        let r = parse_and_validate(input).expect("GLN-qualified UNB must parse ok");
1356        assert_eq!(r.interchange.sender_id, "1234567890123");
1357        assert_eq!(r.interchange.sender_qualifier, "14");
1358        assert_eq!(r.interchange.recipient_id, "9876543210987");
1359        assert_eq!(r.interchange.recipient_qualifier, "14");
1360    }
1361
1362    // UNB DE 0026 (app_ref) is at element index 6 (ISO 9735-1 §6.1.1):
1363    // [4]=control_ref [5]=S005/password [6]=0026/app_ref [7]=0029 [8]=0031/ack [9]=0032/comms [10]=0035/test
1364
1365    #[test]
1366    fn test_indicator_parsed_from_unb() {
1367        // DE 0035 (test indicator) is at element index 10 (ISO 9735-1).
1368        // UNB+...+1 (ctrl) + (S005) + (0026) + (0029) + (0031) + (0032) + 1 (0035)
1369        //                    [5]       [6]       [7]       [8]       [9]     [10]
1370        let input = b"UNB+UNOA:3+S+R+200101:0900+1++++++1'\
1371                      UNH+1+ORDERS:D:96A:UN'\
1372                      BGM+220+PO-001+9'\
1373                      UNT+3+1'\
1374                      UNZ+1+1'";
1375        let r = parse_and_validate(input).expect("test-flagged UNB must parse ok");
1376        assert!(
1377            r.interchange.test_indicator,
1378            "test_indicator should be true"
1379        );
1380        assert!(r.interchange.is_test(), "is_test() convenience must agree");
1381    }
1382
1383    #[test]
1384    fn no_test_indicator_defaults_false() {
1385        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1386        assert!(!r.interchange.test_indicator);
1387        assert!(!r.interchange.is_test());
1388    }
1389
1390    #[test]
1391    fn app_ref_extracted_when_present() {
1392        // DE 0026 (app_ref) is at element index 6; element [5] (S005 password) is empty.
1393        let input = b"UNB+UNOA:3+S+R+200101:0900+1++MYAPP'\
1394                      UNH+1+ORDERS:D:96A:UN'\
1395                      BGM+220+PO-001+9'\
1396                      UNT+3+1'\
1397                      UNZ+1+1'";
1398        let r = parse_and_validate(input).expect("UNB with app_ref must parse ok");
1399        assert_eq!(r.interchange.app_ref.as_deref(), Some("MYAPP"));
1400    }
1401
1402    #[test]
1403    fn app_ref_is_none_when_absent() {
1404        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1405        assert!(r.interchange.app_ref.is_none());
1406    }
1407
1408    #[test]
1409    fn recipient_password_extracted_when_present() {
1410        // DE 0022 (recipient password) is at element index 5 (S005 comp 0).
1411        let input = b"UNB+UNOA:3+S+R+200101:0900+1+MYPASS'\
1412                      UNH+1+ORDERS:D:96A:UN'\
1413                      BGM+220+PO-001+9'\
1414                      UNT+3+1'\
1415                      UNZ+1+1'";
1416        let r = parse_and_validate(input).expect("UNB with password must parse ok");
1417        assert_eq!(r.interchange.recipient_password.as_deref(), Some("MYPASS"));
1418    }
1419
1420    #[test]
1421    fn recipient_password_is_none_when_absent() {
1422        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1423        assert!(r.interchange.recipient_password.is_none());
1424    }
1425
1426    #[test]
1427    fn acknowledgement_request_flag_parsed() {
1428        // DE 0031 (ack request) at element index 8; set to "1".
1429        // Elements: [5]S005="" [6]0026="" [7]0029="" [8]0031="1"
1430        let input = b"UNB+UNOA:3+S+R+200101:0900+1++++1'\
1431                      UNH+1+ORDERS:D:96A:UN'\
1432                      BGM+220+PO-001+9'\
1433                      UNT+3+1'\
1434                      UNZ+1+1'";
1435        let r = parse_and_validate(input).expect("UNB with ack-request must parse ok");
1436        assert!(r.interchange.acknowledgement_request);
1437        assert!(r.interchange.ack_requested());
1438    }
1439
1440    #[test]
1441    fn acknowledgement_request_defaults_false() {
1442        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1443        assert!(!r.interchange.acknowledgement_request);
1444        assert!(!r.interchange.ack_requested());
1445    }
1446
1447    #[test]
1448    fn communications_agreement_id_extracted() {
1449        // DE 0032 at element index 9; elements [5]-[8] empty.
1450        let input = b"UNB+UNOA:3+S+R+200101:0900+1+++++EANCOM'\
1451                      UNH+1+ORDERS:D:96A:UN'\
1452                      BGM+220+PO-001+9'\
1453                      UNT+3+1'\
1454                      UNZ+1+1'";
1455        let r = parse_and_validate(input).expect("UNB with comms-agreement must parse ok");
1456        assert_eq!(
1457            r.interchange.communications_agreement_id.as_deref(),
1458            Some("EANCOM")
1459        );
1460    }
1461
1462    #[test]
1463    fn dangling_unh_without_unt_returns_err() {
1464        let input =
1465            b"UNB+UNOA:3+S+R+200101:0900+1'UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNZ+1+1'";
1466        let result = parse_and_validate(input);
1467        assert!(
1468            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNT")
1469        );
1470    }
1471
1472    #[test]
1473    fn stray_segment_outside_message_returns_err() {
1474        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'";
1475        assert!(matches!(
1476            parse_and_validate(input),
1477            Err(EdifactError::InvalidSegmentForMessage { .. })
1478        ));
1479    }
1480
1481    #[test]
1482    fn missing_unb_sender_component_returns_err() {
1483        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'";
1484        let result = parse_and_validate(input);
1485        assert!(
1486            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 0 }) if tag == "UNB"),
1487            "expected MissingRequiredComponent for empty sender, got: {result:?}"
1488        );
1489    }
1490
1491    #[test]
1492    fn nested_unh_without_closing_previous_message_returns_err() {
1493        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'";
1494        let result = parse_and_validate(input);
1495        assert!(
1496            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNH"),
1497            "expected InvalidSegmentForMessage(UNH), got {result:?}"
1498        );
1499    }
1500
1501    #[test]
1502    fn unt_message_reference_must_match_unh() {
1503        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'";
1504        let result = parse_and_validate(input);
1505        assert!(
1506            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNT")
1507        );
1508    }
1509
1510    #[test]
1511    fn unz_control_reference_must_match_unb() {
1512        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'";
1513        let result = parse_and_validate(input);
1514        assert!(
1515            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNZ")
1516        );
1517    }
1518
1519    #[test]
1520    fn missing_unh_message_type_components_return_err() {
1521        let input =
1522            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'";
1523        let result = parse_and_validate(input);
1524        assert!(
1525            matches!(result, Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 1, component_index: 3 }) if tag == "UNH"),
1526            "got: {result:?}"
1527        );
1528    }
1529
1530    #[test]
1531    fn nested_unz_inside_message_returns_err() {
1532        let input =
1533            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'";
1534        let result = parse_and_validate(input);
1535        assert!(
1536            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNZ")
1537        );
1538    }
1539
1540    #[test]
1541    fn lenient_returns_partial_result_on_count_mismatch() {
1542        // UNZ says 2 but only 1 message — lenient mode must return Some(partial)
1543        // along with the error, not None.
1544        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'";
1545        let owned = parse(input);
1546        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1547        let lenient = validate_envelope_lenient(&segs);
1548        let result = lenient.interchange;
1549        let errors = lenient.errors;
1550        assert!(
1551            result.is_some(),
1552            "lenient mode must return Some even on count mismatch"
1553        );
1554        assert_eq!(errors.len(), 1);
1555        assert!(
1556            matches!(
1557                &errors[0],
1558                EdifactError::MessageCountMismatch {
1559                    expected: 2,
1560                    actual: 1
1561                }
1562            ),
1563            "expected MessageCountMismatch(2,1), got {:?}",
1564            errors[0]
1565        );
1566        let partial = result.unwrap();
1567        assert_eq!(partial.messages.len(), 1);
1568        assert_eq!(partial.interchange.actual_unit_count, 1);
1569        assert_eq!(partial.interchange.declared_unit_count, 2);
1570    }
1571
1572    #[test]
1573    fn lenient_returns_none_on_structural_error() {
1574        // Missing UNB — no structure at all, expect None
1575        let input = b"UNH+1+ORDERS:D:11A:UN:EAN010'BGM+220+PO-1+9'UNT+3+1'UNZ+1+1'";
1576        let owned = parse(input);
1577        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1578        let lenient = validate_envelope_lenient(&segs);
1579        let result = lenient.interchange;
1580        let errors = lenient.errors;
1581        assert!(result.is_none(), "missing UNB must yield None");
1582        assert!(!errors.is_empty());
1583    }
1584
1585    #[test]
1586    fn message_count_convenience_method() {
1587        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1588        assert_eq!(r.message_count(), r.messages.len());
1589        assert_eq!(r.message_count(), 1);
1590    }
1591
1592    #[test]
1593    fn interchange_with_single_functional_group_parses_ok() {
1594        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1595                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1596                      UNH+1+ORDERS:D:96A:UN'\
1597                      BGM+220+PO-001+9'\
1598                      UNT+3+1'\
1599                      UNE+1+1'\
1600                      UNZ+1+1'";
1601        let result = parse_and_validate(input).expect("single-group interchange must parse ok");
1602        assert!(result.has_functional_groups());
1603        assert_eq!(result.functional_groups.len(), 1);
1604        let g = &result.functional_groups[0];
1605        assert_eq!(g.group_id, "ORDERS");
1606        assert_eq!(g.group_ref, "1");
1607        assert_eq!(g.controlling_agency, "UN");
1608        assert_eq!(g.declared_message_count, 1);
1609        assert_eq!(g.actual_message_count, 1);
1610        assert_eq!(result.messages.len(), 1);
1611        assert_eq!(result.messages[0].message_type, "ORDERS");
1612        assert_eq!(result.interchange.declared_unit_count, 1);
1613        assert_eq!(result.interchange.actual_unit_count, 1);
1614    }
1615
1616    #[test]
1617    fn interchange_with_multi_message_group_parses_ok() {
1618        // One group containing 2 messages — UNZ = 1 group, UNE = 2 messages.
1619        // This is the key case that strip_functional_group_segments breaks.
1620        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1621                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1622                      UNH+1+ORDERS:D:96A:UN'\
1623                      BGM+220+PO-001+9'\
1624                      UNT+3+1'\
1625                      UNH+2+ORDERS:D:96A:UN'\
1626                      BGM+220+PO-002+9'\
1627                      UNT+3+2'\
1628                      UNE+2+1'\
1629                      UNZ+1+1'";
1630        let result = parse_and_validate(input).expect("multi-message group must parse ok");
1631        assert!(result.has_functional_groups());
1632        assert_eq!(result.functional_groups.len(), 1);
1633        assert_eq!(result.functional_groups[0].actual_message_count, 2);
1634        assert_eq!(result.messages.len(), 2);
1635        // UNZ = 1 group (not 2 messages): ISO 9735-1 §9.2
1636        assert_eq!(result.interchange.actual_unit_count, 1);
1637        assert_eq!(result.interchange.declared_unit_count, 1);
1638    }
1639
1640    #[test]
1641    fn interchange_with_multiple_groups_parses_ok() {
1642        let input = b"UNB+UNOA:3+S+R+200101:0900+2'\
1643                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1644                      UNH+1+ORDERS:D:96A:UN'\
1645                      BGM+220+PO-001+9'\
1646                      UNT+3+1'\
1647                      UNE+1+1'\
1648                      UNG+INVOIC+S+R+200101:0900+2+UN+D:96A'\
1649                      UNH+2+INVOIC:D:96A:UN'\
1650                      BGM+380+INV-001+9'\
1651                      UNT+3+2'\
1652                      UNE+1+2'\
1653                      UNZ+2+2'";
1654        let result = parse_and_validate(input).expect("multi-group interchange must parse ok");
1655        assert_eq!(result.functional_groups.len(), 2);
1656        assert_eq!(result.functional_groups[0].group_id, "ORDERS");
1657        assert_eq!(result.functional_groups[1].group_id, "INVOIC");
1658        assert_eq!(result.messages.len(), 2);
1659        assert_eq!(result.interchange.declared_unit_count, 2);
1660        assert_eq!(result.interchange.actual_unit_count, 2);
1661    }
1662
1663    #[test]
1664    fn ung_une_count_mismatch_returns_err() {
1665        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1666                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1667                      UNH+1+ORDERS:D:96A:UN'\
1668                      BGM+220+PO-001+9'\
1669                      UNT+3+1'\
1670                      UNE+2+1'\
1671                      UNZ+1+1'";
1672        let result = parse_and_validate(input);
1673        assert!(
1674            matches!(
1675                result,
1676                Err(EdifactError::MessageCountMismatch {
1677                    expected: 2,
1678                    actual: 1
1679                })
1680            ),
1681            "expected MessageCountMismatch(2,1), got {result:?}"
1682        );
1683    }
1684
1685    #[test]
1686    fn une_without_ung_returns_err() {
1687        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1688                      UNE+1+1'\
1689                      UNH+1+ORDERS:D:96A:UN'\
1690                      BGM+220+PO-001+9'\
1691                      UNT+3+1'\
1692                      UNZ+1+1'";
1693        let result = parse_and_validate(input);
1694        assert!(
1695            matches!(result, Err(EdifactError::InvalidSegmentForMessage { ref tag, .. }) if tag == "UNE"),
1696            "expected InvalidSegmentForMessage(UNE), got {result:?}"
1697        );
1698    }
1699
1700    #[test]
1701    fn ung_without_une_returns_err() {
1702        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1703                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1704                      UNH+1+ORDERS:D:96A:UN'\
1705                      BGM+220+PO-001+9'\
1706                      UNT+3+1'\
1707                      UNZ+1+1'";
1708        let result = parse_and_validate(input);
1709        assert!(
1710            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNE"),
1711            "expected MissingSegment(UNE), got {result:?}"
1712        );
1713    }
1714
1715    #[test]
1716    fn une_group_ref_must_match_ung() {
1717        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1718                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1719                      UNH+1+ORDERS:D:96A:UN'\
1720                      BGM+220+PO-001+9'\
1721                      UNT+3+1'\
1722                      UNE+1+999'\
1723                      UNZ+1+1'";
1724        let result = parse_and_validate(input);
1725        assert!(
1726            matches!(result, Err(EdifactError::QualifierMismatch { ref tag, .. }) if tag == "UNE"),
1727            "expected QualifierMismatch(UNE), got {result:?}"
1728        );
1729    }
1730
1731    // ── New-field tests (ISO 9735-1 completeness) ─────────────────────────────
1732
1733    #[test]
1734    fn processing_priority_extracted_when_present() {
1735        // DE 0029 at element index 7: [5]=S005="" [6]=0026="" [7]=0029="A"
1736        let input = b"UNB+UNOA:3+S+R+200101:0900+1+++A'\
1737                      UNH+1+ORDERS:D:96A:UN'\
1738                      BGM+220+PO-001+9'\
1739                      UNT+3+1'\
1740                      UNZ+1+1'";
1741        let r = parse_and_validate(input).expect("UNB with processing_priority must parse ok");
1742        assert_eq!(r.interchange.processing_priority.as_deref(), Some("A"));
1743    }
1744
1745    #[test]
1746    fn processing_priority_is_none_when_absent() {
1747        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1748        assert!(r.interchange.processing_priority.is_none());
1749    }
1750
1751    #[test]
1752    fn sender_routing_address_extracted_when_present() {
1753        // S002: sender_id:qualifier:routing  → comp[2] = routing address
1754        let input = b"UNB+UNOA:3+SENDER:14:ROUTEA+RECIP+200101:0900+1'\
1755                      UNH+1+ORDERS:D:96A:UN'\
1756                      BGM+220+PO-001+9'\
1757                      UNT+3+1'\
1758                      UNZ+1+1'";
1759        let r = parse_and_validate(input).expect("UNB with sender routing must parse ok");
1760        assert_eq!(
1761            r.interchange.sender_routing_address.as_deref(),
1762            Some("ROUTEA")
1763        );
1764        assert!(r.interchange.recipient_routing_address.is_none());
1765    }
1766
1767    #[test]
1768    fn recipient_routing_address_extracted_when_present() {
1769        // S003: recipient_id:qualifier:routing → comp[2] = routing address
1770        let input = b"UNB+UNOA:3+SENDER+RECIP:14:ROUTEB+200101:0900+1'\
1771                      UNH+1+ORDERS:D:96A:UN'\
1772                      BGM+220+PO-001+9'\
1773                      UNT+3+1'\
1774                      UNZ+1+1'";
1775        let r = parse_and_validate(input).expect("UNB with recipient routing must parse ok");
1776        assert!(r.interchange.sender_routing_address.is_none());
1777        assert_eq!(
1778            r.interchange.recipient_routing_address.as_deref(),
1779            Some("ROUTEB")
1780        );
1781    }
1782
1783    #[test]
1784    fn routing_address_is_none_when_absent() {
1785        // Plain S+R without sub-components — no routing addresses in S002/S003
1786        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1787                      UNH+1+ORDERS:D:96A:UN'\
1788                      BGM+220+PO-001+9'\
1789                      UNT+3+1'\
1790                      UNZ+1+1'";
1791        let r = parse_and_validate(input).unwrap();
1792        assert!(r.interchange.sender_routing_address.is_none());
1793        assert!(r.interchange.recipient_routing_address.is_none());
1794    }
1795
1796    #[test]
1797    fn common_access_ref_extracted_when_present() {
1798        // UNH element [2] (DE 0068): common access reference
1799        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1800                      UNH+1+ORDERS:D:96A:UN+COMREF1'\
1801                      BGM+220+PO-001+9'\
1802                      UNT+3+1'\
1803                      UNZ+1+1'";
1804        let r = parse_and_validate(input).expect("UNH with common_access_ref must parse ok");
1805        assert_eq!(r.messages[0].common_access_ref.as_deref(), Some("COMREF1"));
1806    }
1807
1808    #[test]
1809    fn common_access_ref_is_none_when_absent() {
1810        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1811        assert!(r.messages[0].common_access_ref.is_none());
1812    }
1813
1814    #[test]
1815    fn parse_unh_includes_message_ref() {
1816        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1817                      UNH+REF42+ORDERS:D:96A:UN'\
1818                      BGM+220+PO-001+9'\
1819                      UNT+3+REF42'\
1820                      UNZ+1+1'";
1821        // Validate the high-level API which exercises parse_unh internally;
1822        // the message_ref field on MessageEnvelope should equal the UNH DE 0062 value.
1823        let r = parse_and_validate(input).expect("must parse ok");
1824        assert_eq!(r.messages[0].message_ref, "REF42");
1825        assert_eq!(r.messages[0].message_type, "ORDERS");
1826    }
1827
1828    #[test]
1829    fn iter_messages_by_type_returns_matching_messages() {
1830        let input = b"UNB+UNOA:3+S+R+200101:0900+CTRL2'\
1831                      UNH+1+ORDERS:D:96A:UN'\
1832                      BGM+220+PO-001+9'\
1833                      UNT+3+1'\
1834                      UNH+2+INVOIC:D:96A:UN'\
1835                      BGM+380+INV-001+9'\
1836                      UNT+3+2'\
1837                      UNZ+2+CTRL2'";
1838        let r = parse_and_validate(input).expect("two-message interchange must parse ok");
1839        let orders: Vec<_> = r.iter_messages_by_type("ORDERS").collect();
1840        assert_eq!(orders.len(), 1);
1841        assert_eq!(orders[0].message_ref, "1");
1842        let invoices: Vec<_> = r.iter_messages_by_type("INVOIC").collect();
1843        assert_eq!(invoices.len(), 1);
1844        assert_eq!(invoices[0].message_ref, "2");
1845        let none: Vec<_> = r.iter_messages_by_type("DESADV").collect();
1846        assert!(none.is_empty());
1847    }
1848
1849    #[test]
1850    fn display_interchange_envelope() {
1851        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1852        let s = r.interchange.to_string();
1853        assert!(s.contains("SENDER"), "Display must include sender_id");
1854        assert!(s.contains("UNOA"), "Display must include syntax_identifier");
1855    }
1856
1857    #[test]
1858    fn display_message_envelope() {
1859        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1860        let s = r.messages[0].to_string();
1861        assert!(s.contains("ORDERS"), "Display must include message_type");
1862        assert!(s.contains("ref="), "Display must include message ref label");
1863    }
1864
1865    #[test]
1866    fn display_validated_interchange() {
1867        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1868        let s = r.to_string();
1869        assert!(
1870            s.contains("messages="),
1871            "Display must include message count label"
1872        );
1873    }
1874
1875    // ── DE 0001 syntax identifier validation ─────────────────────────────────
1876
1877    #[test]
1878    fn unrecognised_syntax_identifier_returns_err() {
1879        // DE 0001 "XXXX" is not in the ISO 9735-1 §3.1 defined list.
1880        let input = b"UNB+XXXX:3+S+R+200101:0900+1'\
1881                      UNH+1+ORDERS:D:96A:UN'\
1882                      BGM+220+PO-001+9'\
1883                      UNT+3+1'\
1884                      UNZ+1+1'";
1885        let result = parse_and_validate(input);
1886        assert!(
1887            matches!(result, Err(EdifactError::UnrecognisedSyntaxIdentifier(ref id)) if id == "XXXX"),
1888            "expected UnrecognisedSyntaxIdentifier(\"XXXX\"), got {result:?}"
1889        );
1890    }
1891
1892    #[test]
1893    fn all_valid_syntax_identifiers_accepted() {
1894        for id in &["UNOA", "UNOB", "UNOC", "UNOD", "UNOE", "UNOF", "KECA"] {
1895            let input = format!(
1896                "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'"
1897            );
1898            let result = parse_and_validate(input.as_bytes());
1899            assert!(
1900                result.is_ok(),
1901                "syntax id '{id}' should be accepted, got {result:?}"
1902            );
1903        }
1904    }
1905
1906    // ── UNB S005 password qualifier ───────────────────────────────────────────
1907
1908    #[test]
1909    fn recipient_password_qualifier_extracted_when_present() {
1910        // S005: MYPASS:AA — comp[0]=password, comp[1]=qualifier (DE 0025)
1911        let input = b"UNB+UNOA:3+S+R+200101:0900+1+MYPASS:AA'\
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 password+qualifier must parse ok");
1917        assert_eq!(r.interchange.recipient_password.as_deref(), Some("MYPASS"));
1918        assert_eq!(
1919            r.interchange.recipient_password_qualifier.as_deref(),
1920            Some("AA")
1921        );
1922    }
1923
1924    #[test]
1925    fn recipient_password_qualifier_is_none_when_absent() {
1926        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1927        assert!(r.interchange.recipient_password_qualifier.is_none());
1928    }
1929
1930    // ── UNH S010 sequence-of-transfers ───────────────────────────────────────
1931
1932    #[test]
1933    fn sequence_of_transfers_extracted_when_present() {
1934        // UNH element [2] = common access ref, element [3] = S010 (seq:position)
1935        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1936                      UNH+1+ORDERS:D:96A:UN++2:C'\
1937                      BGM+220+PO-001+9'\
1938                      UNT+3+1'\
1939                      UNZ+1+1'";
1940        let r = parse_and_validate(input).expect("UNH with S010 must parse ok");
1941        assert_eq!(r.messages[0].sequence_of_transfers, Some(2));
1942        assert_eq!(r.messages[0].transfer_position.as_deref(), Some("C"));
1943    }
1944
1945    #[test]
1946    fn sequence_of_transfers_none_when_absent() {
1947        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
1948        assert!(r.messages[0].sequence_of_transfers.is_none());
1949        assert!(r.messages[0].transfer_position.is_none());
1950    }
1951
1952    // ── UNG S006/S007 application qualifiers ─────────────────────────────────
1953
1954    #[test]
1955    fn ung_app_sender_and_recipient_qualifiers_extracted() {
1956        // UNG: group_id + S006(app_sender:qualifier) + S007(app_recip:qualifier) + ...
1957        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1958                      UNG+ORDERS+APPSEND:ZZZ+APPRECV:14+200101:0900+1+UN+D:96A'\
1959                      UNH+1+ORDERS:D:96A:UN'\
1960                      BGM+220+PO-001+9'\
1961                      UNT+3+1'\
1962                      UNE+1+1'\
1963                      UNZ+1+1'";
1964        let r = parse_and_validate(input).expect("UNG with qualifiers must parse ok");
1965        let g = &r.functional_groups[0];
1966        assert_eq!(g.app_sender, "APPSEND");
1967        assert_eq!(g.app_sender_qualifier, "ZZZ");
1968        assert_eq!(g.app_recipient, "APPRECV");
1969        assert_eq!(g.app_recipient_qualifier, "14");
1970    }
1971
1972    #[test]
1973    fn ung_qualifiers_empty_when_absent() {
1974        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
1975                      UNG+ORDERS+S+R+200101:0900+1+UN+D:96A'\
1976                      UNH+1+ORDERS:D:96A:UN'\
1977                      BGM+220+PO-001+9'\
1978                      UNT+3+1'\
1979                      UNE+1+1'\
1980                      UNZ+1+1'";
1981        let r = parse_and_validate(input).unwrap();
1982        let g = &r.functional_groups[0];
1983        assert_eq!(g.app_sender_qualifier, "");
1984        assert_eq!(g.app_recipient_qualifier, "");
1985    }
1986
1987    // ── LenientResult methods ─────────────────────────────────────────────────
1988
1989    #[test]
1990    fn lenient_result_is_valid_true_on_clean_interchange() {
1991        let owned = parse(VALID_INTERCHANGE);
1992        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
1993        let r = validate_envelope_lenient(&segs);
1994        assert!(r.is_valid());
1995        assert!(r.errors.is_empty());
1996        assert!(r.interchange.is_some());
1997    }
1998
1999    #[test]
2000    fn lenient_result_into_strict_ok_path() {
2001        let owned = parse(VALID_INTERCHANGE);
2002        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2003        let r = validate_envelope_lenient(&segs);
2004        let strict = r.into_strict();
2005        assert!(
2006            strict.is_ok(),
2007            "into_strict() should succeed for valid interchange"
2008        );
2009        assert_eq!(strict.unwrap().messages.len(), 1);
2010    }
2011
2012    #[test]
2013    fn lenient_result_into_strict_err_path() {
2014        // Count mismatch → into_strict() returns Err with the error
2015        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'";
2016        let owned = parse(input);
2017        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2018        let r = validate_envelope_lenient(&segs);
2019        assert!(!r.is_valid());
2020        let strict = r.into_strict();
2021        assert!(strict.is_err());
2022        let errors = strict.unwrap_err();
2023        assert_eq!(errors.len(), 1);
2024        assert!(matches!(
2025            &errors[0],
2026            EdifactError::MessageCountMismatch {
2027                expected: 2,
2028                actual: 1
2029            }
2030        ));
2031    }
2032
2033    // ── Direct parse_unh / parse_ung API ─────────────────────────────────────
2034
2035    #[test]
2036    fn parse_unh_direct_extracts_all_s009_fields() {
2037        // parse_unh is called internally by extract_messages_flat; all S009 fields
2038        // it extracts surface in the resulting MessageEnvelope.  We verify them here
2039        // rather than calling parse_unh(&seg) from a Vec<Segment<'_>>, which would
2040        // conflict with the SmallVec-based Element drop-check (see API docs).
2041        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2042                      UNH+REF99+ORDERS:D:96A:UN:EAN010'\
2043                      BGM+220+PO-001+9'\
2044                      UNT+3+REF99'\
2045                      UNZ+1+1'";
2046        let r = parse_and_validate(input).expect("must parse ok");
2047        let msg = &r.messages[0];
2048        assert_eq!(msg.message_ref, "REF99");
2049        assert_eq!(msg.message_type, "ORDERS");
2050        assert_eq!(msg.version, "D");
2051        assert_eq!(msg.release, "96A");
2052        assert_eq!(msg.controlling_agency, "UN");
2053        assert_eq!(msg.association_code, "EAN010");
2054    }
2055
2056    #[test]
2057    fn parse_ung_direct_extracts_identifier_fields() {
2058        // parse_ung fields surface via the FunctionalGroupEnvelope returned by validation.
2059        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2060                      UNG+ORDERS+APPSEND:ZZZ+APPRECV:14+200101:0900+GRP01+UN+D:96A'\
2061                      UNH+1+ORDERS:D:96A:UN'\
2062                      BGM+220+PO-001+9'\
2063                      UNT+3+1'\
2064                      UNE+1+GRP01'\
2065                      UNZ+1+1'";
2066        let r = parse_and_validate(input).expect("must parse ok");
2067        let g = &r.functional_groups[0];
2068        assert_eq!(g.group_id, "ORDERS");
2069        assert_eq!(g.app_sender, "APPSEND");
2070        assert_eq!(g.app_sender_qualifier, "ZZZ");
2071        assert_eq!(g.app_recipient, "APPRECV");
2072        assert_eq!(g.app_recipient_qualifier, "14");
2073        assert_eq!(g.group_ref, "GRP01");
2074        assert_eq!(g.controlling_agency, "UN");
2075        assert_eq!(g.version, "D");
2076        assert_eq!(g.release, "96A");
2077    }
2078
2079    // ── find_message convenience method ──────────────────────────────────────
2080
2081    #[test]
2082    fn find_message_returns_correct_message_by_ref() {
2083        let input = b"UNB+UNOA:3+S+R+200101:0900+CTRL2'\
2084                      UNH+REF-A+ORDERS:D:96A:UN'\
2085                      BGM+220+PO-001+9'\
2086                      UNT+3+REF-A'\
2087                      UNH+REF-B+INVOIC:D:96A:UN'\
2088                      BGM+380+INV-001+9'\
2089                      UNT+3+REF-B'\
2090                      UNZ+2+CTRL2'";
2091        let r = parse_and_validate(input).expect("two-message interchange must parse ok");
2092        let msg_a = r.find_message("REF-A");
2093        assert!(msg_a.is_some());
2094        assert_eq!(msg_a.unwrap().message_type, "ORDERS");
2095        let msg_b = r.find_message("REF-B");
2096        assert!(msg_b.is_some());
2097        assert_eq!(msg_b.unwrap().message_type, "INVOIC");
2098        assert!(r.find_message("MISSING").is_none());
2099    }
2100
2101    // ── UNG missing mandatory group_ref ──────────────────────────────────────
2102
2103    #[test]
2104    fn ung_missing_group_ref_returns_err() {
2105        // UNG with element [4] (group ref) empty — must error, not silently use ""
2106        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2107                      UNG+ORDERS+S+R+200101:0900++UN+D:96A'\
2108                      UNH+1+ORDERS:D:96A:UN'\
2109                      BGM+220+PO-001+9'\
2110                      UNT+3+1'\
2111                      UNE+1+'\
2112                      UNZ+1+1'";
2113        let result = parse_and_validate(input);
2114        assert!(
2115            matches!(result,
2116                Err(EdifactError::MissingRequiredComponent { ref tag, element_index: 4, component_index: 0 })
2117                if tag == "UNG"
2118            ),
2119            "expected MissingRequiredComponent for empty UNG group_ref, got {result:?}"
2120        );
2121    }
2122
2123    // ── Edge case and ergonomics tests ────────────────────────────────────────
2124
2125    #[test]
2126    fn empty_segment_list_returns_missing_unb() {
2127        // Contract: validate_envelope(&[]) must return MissingSegment{UNB},
2128        // not panic or return Ok.
2129        let result = validate_envelope(&[]);
2130        assert!(
2131            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNB"),
2132            "expected MissingSegment(UNB) for empty input, got {result:?}"
2133        );
2134    }
2135
2136    #[test]
2137    fn single_segment_only_unb_returns_missing_unz() {
2138        // Only UNB, no UNZ — should fail with MissingSegment{UNZ}.
2139        let input = b"UNB+UNOA:3+S+R+200101:0900+1'";
2140        let owned = parse(input);
2141        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2142        let result = validate_envelope(&segs);
2143        assert!(
2144            matches!(result, Err(EdifactError::MissingSegment { ref tag, .. }) if tag == "UNZ"),
2145            "expected MissingSegment(UNZ) for UNB-only input, got {result:?}"
2146        );
2147    }
2148
2149    #[test]
2150    fn display_validated_interchange_includes_count() {
2151        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2152        let s = r.to_string();
2153        // Must include the numeric count, not just the label
2154        assert!(
2155            s.contains("messages=1"),
2156            "Display must include count '1': {s}"
2157        );
2158    }
2159
2160    #[test]
2161    fn display_interchange_envelope_contains_arrow() {
2162        let r = parse_and_validate(VALID_INTERCHANGE).unwrap();
2163        let s = r.interchange.to_string();
2164        // Must use ASCII arrow, not Unicode →
2165        assert!(s.contains("->"), "Display must use ASCII '->' arrow: {s}");
2166        assert!(
2167            !s.contains('\u{2192}'),
2168            "Display must not use Unicode → arrow: {s}"
2169        );
2170    }
2171
2172    #[test]
2173    fn display_functional_group_envelope() {
2174        let input = b"UNB+UNOA:3+S+R+200101:0900+1'\
2175                      UNG+ORDERS+APPSEND+APPRECV+200101:0900+GRP01+UN+D:96A'\
2176                      UNH+1+ORDERS:D:96A:UN'\
2177                      BGM+220+PO-001+9'\
2178                      UNT+3+1'\
2179                      UNE+1+GRP01'\
2180                      UNZ+1+1'";
2181        let r = parse_and_validate(input).expect("must parse ok");
2182        let g = &r.functional_groups[0];
2183        let s = g.to_string();
2184        assert!(s.contains("ORDERS"), "Display must include group_id: {s}");
2185        assert!(
2186            s.contains("APPSEND"),
2187            "Display must include app_sender: {s}"
2188        );
2189        assert!(
2190            s.contains("APPRECV"),
2191            "Display must include app_recipient: {s}"
2192        );
2193        assert!(s.contains("GRP01"), "Display must include group_ref: {s}");
2194        assert!(
2195            s.contains("msgs="),
2196            "Display must include message count label: {s}"
2197        );
2198        assert!(
2199            s.contains("1/1"),
2200            "Display must include actual/declared counts: {s}"
2201        );
2202    }
2203
2204    #[test]
2205    fn lenient_has_errors_is_inverse_of_is_valid() {
2206        let owned = parse(VALID_INTERCHANGE);
2207        let segs: Vec<Segment<'_>> = owned.iter().map(crate::OwnedSegment::as_borrowed).collect();
2208        let valid = validate_envelope_lenient(&segs);
2209        assert!(valid.is_valid());
2210        assert!(!valid.has_errors());
2211
2212        // Count mismatch: is_valid() == false, has_errors() == true
2213        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'";
2214        let owned2 = parse(input);
2215        let segs2: Vec<Segment<'_>> = owned2
2216            .iter()
2217            .map(crate::OwnedSegment::as_borrowed)
2218            .collect();
2219        let invalid = validate_envelope_lenient(&segs2);
2220        assert!(!invalid.is_valid());
2221        assert!(invalid.has_errors());
2222    }
2223}