Skip to main content

edifact_rs/
writer.rs

1//! EDIFACT writer — serializes [`Segment`]s to wire format.
2
3use crate::{error::EdifactError, model::Segment, tokenizer::ServiceStringAdvice};
4use std::borrow::Cow;
5use std::io::Write;
6
7/// One data element of a segment being written: simple or composite.
8///
9/// The everyday EDIFACT segment mixes both shapes — `NAD+MS+id::agency`,
10/// `DTM+137:20260101:102` — and this enum lets a single call express that
11/// without pre-joining components into a string (which loses the distinction
12/// between a separator and a literal `:` in a value).
13///
14/// `From` impls cover the common literals, so `"MS".into()` and
15/// `["a", "", "b"].into()` both work; the [`elements!`][crate::elements] macro
16/// wraps that up entirely.
17///
18/// # Example
19///
20/// ```rust
21/// use edifact_rs::{DataElement, Writer};
22///
23/// let mut w = Writer::new(Vec::new());
24/// w.write_elements(
25///     "NAD",
26///     &[
27///         DataElement::Simple("MS"),
28///         DataElement::Composite(&["9900112233445", "", "293"]),
29///     ],
30/// )?;
31/// assert_eq!(w.finish()?, b"NAD+MS+9900112233445::293'".to_vec());
32/// # Ok::<(), edifact_rs::EdifactError>(())
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DataElement<'a> {
36    /// A simple data element — one value, no component separators.
37    Simple(&'a str),
38    /// A composite data element — components written in order, separated by the
39    /// active component separator.  A separator byte *inside* a component value
40    /// is escaped rather than promoted to a boundary.
41    Composite(&'a [&'a str]),
42}
43
44impl<'a> DataElement<'a> {
45    /// The components of this element, as a slice.
46    #[inline]
47    #[must_use]
48    pub fn components(&self) -> &[&'a str] {
49        match self {
50            Self::Simple(value) => std::slice::from_ref(value),
51            Self::Composite(components) => components,
52        }
53    }
54}
55
56impl<'a> From<&'a str> for DataElement<'a> {
57    #[inline]
58    fn from(value: &'a str) -> Self {
59        Self::Simple(value)
60    }
61}
62
63impl<'a> From<&'a [&'a str]> for DataElement<'a> {
64    #[inline]
65    fn from(components: &'a [&'a str]) -> Self {
66        Self::Composite(components)
67    }
68}
69
70impl<'a, const N: usize> From<&'a [&'a str; N]> for DataElement<'a> {
71    #[inline]
72    fn from(components: &'a [&'a str; N]) -> Self {
73        Self::Composite(components)
74    }
75}
76
77/// Borrow a value as a [`DataElement`], choosing simple or composite by type.
78///
79/// A single string borrows as [`DataElement::Simple`]; an array, slice, or `Vec`
80/// of strings borrows as [`DataElement::Composite`]. This is what lets the
81/// [`elements!`][crate::elements] macro accept both shapes from arbitrary
82/// expressions rather than only from literals.
83///
84/// # Example
85///
86/// ```rust
87/// use edifact_rs::{AsDataElement, DataElement};
88///
89/// let qualifier = String::from("MS");
90/// let party = ["9900112233445", "", "293"];
91///
92/// assert_eq!(qualifier.as_data_element(), DataElement::Simple("MS"));
93/// assert_eq!(
94///     party.as_data_element(),
95///     DataElement::Composite(&["9900112233445", "", "293"]),
96/// );
97/// ```
98pub trait AsDataElement {
99    /// Borrow `self` as a [`DataElement`].
100    fn as_data_element(&self) -> DataElement<'_>;
101}
102
103impl AsDataElement for str {
104    #[inline]
105    fn as_data_element(&self) -> DataElement<'_> {
106        DataElement::Simple(self)
107    }
108}
109
110impl AsDataElement for &str {
111    #[inline]
112    fn as_data_element(&self) -> DataElement<'_> {
113        DataElement::Simple(self)
114    }
115}
116
117impl AsDataElement for String {
118    #[inline]
119    fn as_data_element(&self) -> DataElement<'_> {
120        DataElement::Simple(self.as_str())
121    }
122}
123
124impl AsDataElement for Cow<'_, str> {
125    #[inline]
126    fn as_data_element(&self) -> DataElement<'_> {
127        DataElement::Simple(self.as_ref())
128    }
129}
130
131impl<const N: usize> AsDataElement for [&str; N] {
132    #[inline]
133    fn as_data_element(&self) -> DataElement<'_> {
134        DataElement::Composite(self)
135    }
136}
137
138impl AsDataElement for [&str] {
139    #[inline]
140    fn as_data_element(&self) -> DataElement<'_> {
141        DataElement::Composite(self)
142    }
143}
144
145impl AsDataElement for Vec<&str> {
146    #[inline]
147    fn as_data_element(&self) -> DataElement<'_> {
148        DataElement::Composite(self)
149    }
150}
151
152impl AsDataElement for DataElement<'_> {
153    #[inline]
154    fn as_data_element(&self) -> DataElement<'_> {
155        *self
156    }
157}
158
159/// Build a `&[`[`DataElement`]`]` from a mix of simple values and component lists.
160///
161/// Each entry is an arbitrary expression borrowed through
162/// [`AsDataElement`]: a string becomes a simple data element, an array or slice
163/// of strings becomes a composite. This is the shorthand for the mixed-segment
164/// shape that dominates real EDIFACT:
165///
166/// ```rust
167/// use edifact_rs::{Writer, elements};
168///
169/// // Runtime values, not just literals — the everyday builder shape.
170/// let qualifier = String::from("MS");
171/// let gln = "9900112233445";
172///
173/// let mut w = Writer::new(Vec::new());
174/// w.write_elements("NAD", elements![qualifier.as_str(), [gln, "", "293"]])?;
175/// w.write_elements("DTM", elements![["137", "20260101", "102"]])?;
176/// assert_eq!(
177///     w.finish()?,
178///     b"NAD+MS+9900112233445::293'DTM+137:20260101:102'".to_vec(),
179/// );
180/// # Ok::<(), edifact_rs::EdifactError>(())
181/// ```
182///
183/// Composite components must be string *slices*: a `[String; N]` cannot borrow
184/// as `&[&str]` without allocating, so write `[id.as_str(), "", agency]`.
185///
186/// The expansion borrows temporaries, so the result must be consumed within the
187/// same statement — passing it directly as an argument, as above, always is.
188#[macro_export]
189macro_rules! elements {
190    () => {
191        &[] as &[$crate::DataElement<'_>]
192    };
193    ($($element:expr),+ $(,)?) => {
194        &[$($crate::AsDataElement::as_data_element(&$element)),+][..]
195    };
196}
197
198/// Streaming EDIFACT writer.
199///
200/// Wraps any [`Write`] implementation and serializes segments one at a time.
201/// Call [`Writer::finish`] to flush and get the underlying writer back.
202///
203/// # Wrap unbuffered sinks
204///
205/// `Writer` issues a separate write for each tag, delimiter, and value chunk, so
206/// a segment costs roughly one write per component. Against an in-memory
207/// `Vec<u8>` that is free, but against a [`File`][std::fs::File] or a socket each
208/// one is a syscall.
209///
210/// The writer deliberately does **not** buffer internally: an internal buffer
211/// would silently discard everything not yet flushed if the writer were dropped
212/// without [`finish`][Self::finish]. Wrap the sink instead, which makes the
213/// buffering visible and keeps the flush contract in one place:
214///
215/// ```rust
216/// use std::io::BufWriter;
217/// use edifact_rs::Writer;
218///
219/// let sink = Vec::new(); // stands in for a File or TcpStream
220/// let mut writer = Writer::new(BufWriter::new(sink));
221/// writer.write_raw("BGM", &["220"])?;
222/// // `finish` flushes the `Writer` and hands the `BufWriter` back.
223/// let buffered = writer.finish()?;
224/// assert_eq!(buffered.into_inner().unwrap(), b"BGM+220'".to_vec());
225/// # Ok::<(), edifact_rs::EdifactError>(())
226/// ```
227pub struct Writer<W: Write> {
228    inner: W,
229    ssa: ServiceStringAdvice,
230    /// Running count of segments written.  `u64` to prevent silent overflow on
231    /// pathological inputs (a `u32` would wrap after ~4 billion segments).
232    segment_count: u64,
233    /// `segment_count` as of the most recent `UNH`, used by [`Writer::finish_unt`]
234    /// to derive a per-message DE 0074 rather than a writer-lifetime total.
235    message_start_count: u64,
236    /// Whether the segment currently being written incrementally (via the
237    /// event-emitter path) is a `UNH`.  The whole-segment methods pass the tag
238    /// to `end_segment` directly; the emitter only sees it at `StartSegment`.
239    open_segment_is_unh: bool,
240}
241
242/// Return the offset of the first byte in `hay` that must be release-escaped.
243///
244/// The escape set is the four splitting delimiters plus the repetition separator
245/// when the active UNA declares one.  A space at UNA position 7 is the
246/// conventional "not used" sentinel and is never escaped.
247#[inline]
248fn find_escape(ssa: &ServiceStringAdvice, hay: &[u8]) -> Option<usize> {
249    let first = memchr::memchr3(ssa.element_sep, ssa.component_sep, ssa.release_char, hay);
250    let second = if ssa.repetition_sep == b' ' {
251        memchr::memchr(ssa.segment_term, hay)
252    } else {
253        memchr::memchr2(ssa.segment_term, ssa.repetition_sep, hay)
254    };
255    match (first, second) {
256        (None, None) => None,
257        (Some(a), None) => Some(a),
258        (None, Some(b)) => Some(b),
259        (Some(a), Some(b)) => Some(a.min(b)),
260    }
261}
262
263impl<W: Write> Writer<W> {
264    /// Create a new writer with default EDIFACT delimiters.
265    pub fn new(inner: W) -> Self {
266        Self {
267            inner,
268            ssa: ServiceStringAdvice::default(),
269            segment_count: 0,
270            message_start_count: 0,
271            open_segment_is_unh: false,
272        }
273    }
274
275    /// Create a writer with custom delimiters and write a UNA segment first.
276    pub fn with_una(mut inner: W, ssa: ServiceStringAdvice) -> Result<Self, EdifactError> {
277        // All five active service characters must be mutually distinct, non-whitespace,
278        // and within the ASCII range so they never bisect multi-byte UTF-8 sequences.
279        if !ssa.is_valid() {
280            return Err(EdifactError::InvalidUna);
281        }
282        // UNA: component_sep, element_sep, decimal_mark, release_char, repetition_sep, segment_term
283        let una = [
284            b'U',
285            b'N',
286            b'A',
287            ssa.component_sep,
288            ssa.element_sep,
289            ssa.decimal_mark,
290            ssa.release_char,
291            ssa.repetition_sep,
292            ssa.segment_term,
293        ];
294        inner.write_all(&una)?;
295        Ok(Self {
296            inner,
297            ssa,
298            segment_count: 0,
299            message_start_count: 0,
300            open_segment_is_unh: false,
301        })
302    }
303
304    /// Record the end of a segment: terminator, count, and `UNH` bookkeeping.
305    ///
306    /// Every emit path funnels through here.  Two of them used to forget the
307    /// `UNH` marker, so [`finish_unt`][Self::finish_unt] derived DE 0074 from
308    /// the writer-lifetime total whenever a message header happened to be
309    /// written with [`write_segment`][Self::write_segment].
310    #[inline]
311    fn end_segment(&mut self, tag: &str) -> Result<(), EdifactError> {
312        self.inner.write_all(&[self.ssa.segment_term])?;
313        if tag == "UNH" {
314            self.message_start_count = self.segment_count;
315        }
316        self.segment_count += 1;
317        Ok(())
318    }
319
320    /// Write a single segment, including any ISO 9735-4 repetitions.
321    pub fn write_segment(&mut self, seg: &Segment<'_>) -> Result<(), EdifactError> {
322        self.inner.write_all(seg.tag.as_bytes())?;
323
324        for element in &seg.elements {
325            self.inner.write_all(&[self.ssa.element_sep])?;
326            if !element.repeats.is_empty() && !self.ssa.is_repetition_active() {
327                // The sentinel at UNA position 7 is a space.  Emitting it as a
328                // separator would produce output that reads back as a single
329                // occurrence whose value contains a space — corrupt, and quietly
330                // so.  Refusing is the only honest option.
331                return Err(EdifactError::RepetitionSeparatorNotDeclared);
332            }
333            for (repetition, components) in element.repetitions().enumerate() {
334                if repetition > 0 {
335                    self.inner.write_all(&[self.ssa.repetition_sep])?;
336                }
337                for (i, (component, _)) in components.iter().enumerate() {
338                    if i > 0 {
339                        self.inner.write_all(&[self.ssa.component_sep])?;
340                    }
341                    self.write_escaped(component)?;
342                }
343            }
344        }
345
346        self.end_segment(seg.tag)
347    }
348
349    /// Write a raw segment from tag + element string slices.
350    ///
351    /// Each element string is split on the **active component-separator byte** from the
352    /// configured [`ServiceStringAdvice`][crate::ServiceStringAdvice] to identify component
353    /// boundaries.  The default component separator is `:` (0x3A), but this can differ when a
354    /// non-default `UNA` string was used to construct the writer.
355    ///
356    /// # Delimiter dependency
357    ///
358    /// Callers that embed the literal `:` character in element strings rely on `:` being
359    /// the component separator.  When the writer uses a non-default delimiter set, `:` will
360    /// **not** be treated as a component boundary and the segment will be written incorrectly.
361    ///
362    /// **UTF-8 safety**: EDIFACT syntax requires all delimiter bytes to be single-byte ASCII
363    /// characters (values 0x00–0x7F).  Non-ASCII delimiter bytes would bisect multi-byte UTF-8
364    /// sequences in data values and produce malformed output.  All fields of
365    /// [`ServiceStringAdvice`][crate::ServiceStringAdvice] must therefore hold ASCII byte values.
366    ///
367    /// To produce correct output regardless of the active delimiter, prefer
368    /// [`Self::write_elements`] — it takes component boundaries explicitly and
369    /// handles the mixed simple/composite shape that most real segments have.
370    /// [`Self::write_segment_parts`] is the equivalent for owned data.
371    pub fn write_raw(&mut self, tag: &str, elements: &[&str]) -> Result<(), EdifactError> {
372        self.inner.write_all(tag.as_bytes())?;
373        let comp_sep = self.ssa.component_sep;
374        for el in elements {
375            self.inner.write_all(&[self.ssa.element_sep])?;
376            // Byte-level split: EDIFACT delimiters are always single bytes.
377            let mut parts = el.as_bytes().split(|&b| b == comp_sep);
378            if let Some(first) = parts.next() {
379                // INVARIANT: input is valid UTF-8 and we split on a single-byte ASCII
380                // delimiter, so each part remains a valid UTF-8 slice.
381                self.write_escaped(
382                    std::str::from_utf8(first).map_err(|_| EdifactError::InvalidUtf8)?,
383                )?;
384            }
385            for part in parts {
386                self.inner.write_all(&[comp_sep])?;
387                self.write_escaped(
388                    std::str::from_utf8(part).map_err(|_| EdifactError::InvalidUtf8)?,
389                )?;
390            }
391        }
392        self.end_segment(tag)
393    }
394
395    /// Write a segment from a tag and pre-split element/component data.
396    ///
397    /// `elements` is a slice of elements; each element is a sequence of component strings.
398    /// This avoids the lifetime constraints of [`Self::write_segment`] when building
399    /// segments from runtime-owned data (e.g. inside [`crate::WriterEmitter`]).
400    pub fn write_segment_parts<E>(&mut self, tag: &str, elements: &[E]) -> Result<(), EdifactError>
401    where
402        E: AsRef<[String]>,
403    {
404        self.inner.write_all(tag.as_bytes())?;
405        for element in elements {
406            self.inner.write_all(&[self.ssa.element_sep])?;
407            let mut first = true;
408            for comp in element.as_ref() {
409                if !first {
410                    self.inner.write_all(&[self.ssa.component_sep])?;
411                }
412                first = false;
413                self.write_escaped(comp.as_str())?;
414            }
415        }
416        self.end_segment(tag)
417    }
418
419    /// Write a segment from a tag and borrowed element/component slices.
420    ///
421    /// Unlike [`Self::write_raw`], component boundaries are given explicitly
422    /// rather than inferred by splitting on the active component separator, so
423    /// values containing a literal separator byte are escaped instead of being
424    /// silently reinterpreted as a composite boundary.  Unlike
425    /// [`Self::write_segment_parts`], no `String` allocation is required.
426    ///
427    /// # Example
428    ///
429    /// ```
430    /// use edifact_rs::Writer;
431    /// let mut w = Writer::new(Vec::new());
432    /// // The `:` inside the sender id stays part of the value.
433    /// w.write_composites("NAD", &[&["MS"][..], &["ACME:INC"][..]])?;
434    /// assert_eq!(w.finish()?, b"NAD+MS+ACME?:INC'".to_vec());
435    /// # Ok::<(), edifact_rs::EdifactError>(())
436    /// ```
437    ///
438    /// # Errors
439    ///
440    /// Returns [`EdifactError`] if the underlying writer fails.
441    pub fn write_composites(
442        &mut self,
443        tag: &str,
444        elements: &[&[&str]],
445    ) -> Result<(), EdifactError> {
446        self.inner.write_all(tag.as_bytes())?;
447        for element in elements {
448            self.inner.write_all(&[self.ssa.element_sep])?;
449            for (i, comp) in element.iter().enumerate() {
450                if i > 0 {
451                    self.inner.write_all(&[self.ssa.component_sep])?;
452                }
453                self.write_escaped(comp)?;
454            }
455        }
456        self.end_segment(tag)
457    }
458
459    /// Write a segment whose data elements mix simple and composite shapes.
460    ///
461    /// This is the general form of segment emission and the one that matches
462    /// how EDIFACT segments are actually specified: `NAD` takes a simple
463    /// qualifier followed by a composite party identification, `DTM` takes a
464    /// single composite.  [`write_raw`][Self::write_raw] (all-simple, with
465    /// separators inferred by splitting) and
466    /// [`write_composites`][Self::write_composites] (all-composite) are the two
467    /// special cases.
468    ///
469    /// Component boundaries are explicit, so a value containing the active
470    /// component separator is escaped rather than silently promoted to a
471    /// boundary.  Nothing is allocated.
472    ///
473    /// # Example
474    ///
475    /// ```rust
476    /// use edifact_rs::{DataElement, Writer, elements};
477    ///
478    /// let mut w = Writer::new(Vec::new());
479    /// // Explicit form …
480    /// w.write_elements(
481    ///     "NAD",
482    ///     &[DataElement::Simple("MS"), DataElement::Composite(&["ACME:INC", "", "9"])],
483    /// )?;
484    /// // … or the `elements!` shorthand.
485    /// w.write_elements("DTM", elements![["137", "20260101", "102"]])?;
486    /// assert_eq!(
487    ///     w.finish()?,
488    ///     b"NAD+MS+ACME?:INC::9'DTM+137:20260101:102'".to_vec(),
489    /// );
490    /// # Ok::<(), edifact_rs::EdifactError>(())
491    /// ```
492    ///
493    /// # Errors
494    ///
495    /// Returns [`EdifactError`] if the underlying writer fails.
496    pub fn write_elements(
497        &mut self,
498        tag: &str,
499        elements: &[DataElement<'_>],
500    ) -> Result<(), EdifactError> {
501        self.inner.write_all(tag.as_bytes())?;
502        for element in elements {
503            self.inner.write_all(&[self.ssa.element_sep])?;
504            for (i, comp) in element.components().iter().enumerate() {
505                if i > 0 {
506                    self.inner.write_all(&[self.ssa.component_sep])?;
507                }
508                self.write_escaped(comp)?;
509            }
510        }
511        self.end_segment(tag)
512    }
513
514    /// Flush and return the underlying writer.
515    pub fn finish(mut self) -> Result<W, EdifactError> {
516        self.inner.flush()?;
517        Ok(self.inner)
518    }
519
520    /// Write the `UNT` segment and return the inner writer.
521    ///
522    /// The count written into `UNT` DE 0074 covers the current message only:
523    /// `UNH`, every segment written since it, and `UNT` itself.  Segments written
524    /// before the message's `UNH` — an interchange-level `UNB`, or a preceding
525    /// message — are excluded, as EDIFACT requires.
526    ///
527    /// If no `UNH` has been written, the count falls back to every segment
528    /// written so far plus one.
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if writing fails.  Do **not** call [`write_raw`][Self::write_raw] or
533    /// [`write_segment`][Self::write_segment] after `finish_unt` — the writer is consumed.
534    pub fn finish_unt(mut self, message_ref: &str) -> Result<W, EdifactError> {
535        // DE 0074 counts UNH + content + UNT.  `message_start_count` is the
536        // absolute segment count immediately after UNH, so content is
537        // `segment_count - message_start_count` and the total adds UNH and UNT.
538        let count = self.segment_count - self.message_start_count + 1;
539        let count_str = count.to_string();
540        self.write_composites("UNT", &[&[count_str.as_str()], &[message_ref]])?;
541        self.finish()
542    }
543
544    /// Returns the total number of segments written so far.
545    pub fn segment_count(&self) -> u64 {
546        self.segment_count
547    }
548
549    /// Returns the active [`ServiceStringAdvice`] (delimiter configuration).
550    pub fn service_string_advice(&self) -> ServiceStringAdvice {
551        self.ssa
552    }
553
554    /// Escape a value string for inclusion in an EDIFACT segment.
555    ///
556    /// Any character in `value` that matches the active element separator,
557    /// component separator, release character, or segment terminator is escaped
558    /// by prefixing it with the release character (default `?`).
559    ///
560    /// Returns a borrowed `Cow::Borrowed(value)` when no escaping is needed,
561    /// avoiding an allocation on the fast path.
562    ///
563    /// # Example
564    ///
565    /// ```rust,ignore
566    /// let writer = Writer::new(std::io::sink());
567    /// // '+' must be escaped since it is the default element separator.
568    /// assert_eq!(writer.escape_value("price+tax"), "price?+tax");
569    /// ```
570    pub fn escape_value<'v>(&self, value: &'v str) -> Cow<'v, str> {
571        let bytes = value.as_bytes();
572        if find_escape(&self.ssa, bytes).is_none() {
573            return Cow::Borrowed(value);
574        }
575        // Built as a `String` from the start.  Assembling a `Vec<u8>` and then
576        // re-validating it needed a fallible conversion whose failure branch was
577        // unreachable, which is exactly the kind of `expect` that has no business
578        // in a library.  Every delimiter is single-byte ASCII (enforced by
579        // `ServiceStringAdvice::is_valid`), so each hit lands on a character
580        // boundary and both halves of the split are valid `&str`.
581        let release = self.ssa.release_char as char;
582        let mut out = String::with_capacity(value.len() + 4);
583        let mut last = 0;
584        while let Some(hit) = find_escape(&self.ssa, &bytes[last..]) {
585            let abs = last + hit;
586            out.push_str(&value[last..abs]);
587            out.push(release);
588            out.push(bytes[abs] as char);
589            last = abs + 1;
590        }
591        out.push_str(&value[last..]);
592        Cow::Owned(out)
593    }
594    /// Write only the segment tag bytes — no element separator or terminator.
595    ///
596    /// Used by [`crate::WriterEmitter`] for eager, zero-allocation event writing.
597    #[inline]
598    pub(crate) fn write_tag_only(&mut self, tag: &str) -> Result<(), EdifactError> {
599        self.inner.write_all(tag.as_bytes())?;
600        self.open_segment_is_unh = tag == "UNH";
601        Ok(())
602    }
603
604    /// Write one element separator byte.
605    #[inline]
606    pub(crate) fn write_element_sep(&mut self) -> Result<(), EdifactError> {
607        self.inner.write_all(&[self.ssa.element_sep])?;
608        Ok(())
609    }
610
611    /// Write one component separator byte.
612    #[inline]
613    pub(crate) fn write_component_sep(&mut self) -> Result<(), EdifactError> {
614        self.inner.write_all(&[self.ssa.component_sep])?;
615        Ok(())
616    }
617
618    /// Write the segment terminator and increment the internal segment counter.
619    #[inline]
620    pub(crate) fn write_segment_term_and_count(&mut self) -> Result<(), EdifactError> {
621        let tag = if self.open_segment_is_unh { "UNH" } else { "" };
622        self.open_segment_is_unh = false;
623        self.end_segment(tag)
624    }
625
626    /// Write a value, escaping any delimiter characters.
627    pub(crate) fn write_escaped(&mut self, value: &str) -> Result<(), EdifactError> {
628        let release = self.ssa.release_char;
629        let bytes = value.as_bytes();
630        let mut last = 0;
631        let mut pos = 0;
632        while pos < bytes.len() {
633            let Some(hit) = find_escape(&self.ssa, &bytes[pos..]) else {
634                break;
635            };
636            let abs = pos + hit;
637            if abs > last {
638                self.inner.write_all(&bytes[last..abs])?;
639            }
640            self.inner.write_all(&[release, bytes[abs]])?;
641            last = abs + 1;
642            pos = abs + 1;
643        }
644        self.inner.write_all(&bytes[last..])?;
645        Ok(())
646    }
647
648    // ── Interchange envelope helpers ──────────────────────────────────────────
649
650    /// Write a `UNB` interchange header segment.
651    ///
652    /// Generates:
653    /// ```text
654    /// UNB+<syntax_id>:<syntax_version>+<sender>+<recipient>+<date>:<time>+<control_ref>'
655    /// ```
656    ///
657    /// Composite components (S001 syntax identifier/version, S004 date/time) are
658    /// passed separately rather than pre-joined with `:`, so they are written
659    /// with the writer's *active* component separator and so a literal separator
660    /// inside `sender`, `recipient`, or `control_ref` is escaped rather than
661    /// silently promoted to a component boundary.
662    ///
663    /// Track the `control_ref` — it must be repeated in the matching
664    /// [`end_interchange`](Self::end_interchange) call.
665    ///
666    /// # Example
667    ///
668    /// ```
669    /// use edifact_rs::Writer;
670    /// let mut w = Writer::new(Vec::new());
671    /// w.begin_interchange("UNOA", "1", "SENDER", "RECEIVER", "200101", "0900", "IC1")?;
672    /// assert_eq!(
673    ///     w.finish()?,
674    ///     b"UNB+UNOA:1+SENDER+RECEIVER+200101:0900+IC1'".to_vec(),
675    /// );
676    /// # Ok::<(), edifact_rs::EdifactError>(())
677    /// ```
678    ///
679    /// # Errors
680    ///
681    /// Returns [`EdifactError`] if writing fails.
682    #[allow(clippy::too_many_arguments)]
683    pub fn begin_interchange(
684        &mut self,
685        syntax_id: &str,
686        syntax_version: &str,
687        sender: &str,
688        recipient: &str,
689        date: &str,
690        time: &str,
691        control_ref: &str,
692    ) -> Result<(), EdifactError> {
693        self.write_composites(
694            "UNB",
695            &[
696                &[syntax_id, syntax_version],
697                &[sender],
698                &[recipient],
699                &[date, time],
700                &[control_ref],
701            ],
702        )
703    }
704
705    /// Write a `UNH` message header and return a [`MessageWriter`] guard.
706    ///
707    /// The guard tracks the per-message segment count automatically.  Call
708    /// [`MessageWriter::finish`] when all message segments have been written — this
709    /// writes the matching `UNT` segment with the correct count.  If `finish` is not
710    /// called, `Drop` will attempt to write `UNT` as a best-effort fallback (errors
711    /// are silently discarded on drop; prefer explicit `finish`).
712    ///
713    /// Generates:
714    /// ```text
715    /// UNH+<message_ref>+<message_type>:<version>:<release>:<controlling_agency>'
716    /// ```
717    ///
718    /// # Errors
719    ///
720    /// Returns [`EdifactError`] if writing the `UNH` segment fails.
721    pub fn begin_message<'w>(
722        &'w mut self,
723        message_ref: &str,
724        message_type: &str,
725        version: &str,
726        release: &str,
727        controlling_agency: &str,
728    ) -> Result<MessageWriter<'w, W>, EdifactError> {
729        // Build S009 as an explicit composite.  Formatting it with a literal `:`
730        // and handing it to `write_raw` produced a single collapsed component
731        // whenever the writer used a non-default component separator.
732        self.write_composites(
733            "UNH",
734            &[
735                &[message_ref],
736                &[message_type, version, release, controlling_agency],
737            ],
738        )?;
739        // Capture `segment_count` after writing UNH so `MessageWriter` knows
740        // the absolute count that includes UNH.
741        let unh_count = self.segment_count;
742        Ok(MessageWriter {
743            writer: self,
744            message_ref: message_ref.to_owned(),
745            unh_count,
746            finished: false,
747        })
748    }
749
750    /// Write a `UNZ` interchange trailer segment.
751    ///
752    /// `message_count` is the number of `UNH`/`UNT` message pairs in the
753    /// interchange.  `control_ref` must match the value passed to
754    /// [`begin_interchange`](Self::begin_interchange).
755    ///
756    /// If you used [`begin_message`](Self::begin_message) for every message in the
757    /// interchange, `message_count` equals the number of times you called that
758    /// method.
759    ///
760    /// # Errors
761    ///
762    /// Returns [`EdifactError`] if writing fails.
763    pub fn end_interchange(
764        &mut self,
765        message_count: u32,
766        control_ref: &str,
767    ) -> Result<(), EdifactError> {
768        let msg_count_str = message_count.to_string();
769        self.write_composites("UNZ", &[&[msg_count_str.as_str()], &[control_ref]])
770    }
771}
772
773/// RAII guard for a single EDIFACT message within an interchange.
774///
775/// Obtained from [`Writer::begin_message`].  Writes `UNH` on creation and
776/// `UNT` (with the correct per-message segment count) when [`finish`](Self::finish)
777/// is called or the guard is dropped.
778///
779/// Always prefer calling [`finish`](Self::finish) explicitly so that write
780/// errors can be propagated.  The `Drop` impl writes `UNT` as a best-effort
781/// fallback but silently discards I/O errors.
782///
783/// # Example
784///
785/// ```rust,no_run
786/// # use edifact_rs::{Writer, Segment};
787/// # fn example() -> Result<(), edifact_rs::EdifactError> {
788/// let mut writer = Writer::new(Vec::new());
789/// writer.begin_interchange("UNOA", "1", "SENDER", "RECEIVER", "200101", "0900", "1")?;
790/// {
791///     let mut msg = writer.begin_message("1", "ORDERS", "D", "96A", "UN")?;
792///     msg.write_raw("BGM", &["220", "PO001", "9"])?;
793///     msg.finish()?;
794/// }
795/// writer.end_interchange(1, "1")?;
796/// # Ok(())
797/// # }
798/// ```
799pub struct MessageWriter<'w, W: Write> {
800    writer: &'w mut Writer<W>,
801    message_ref: String,
802    /// Absolute segment count immediately after `UNH` was written.
803    unh_count: u64,
804    /// Set to `true` once `finish()` has been called to prevent a double-write
805    /// from the `Drop` impl.
806    finished: bool,
807}
808
809impl<W: Write> MessageWriter<'_, W> {
810    /// Write a segment within this message.
811    ///
812    /// Delegates to [`Writer::write_raw`].
813    pub fn write_raw(&mut self, tag: &str, elements: &[&str]) -> Result<(), EdifactError> {
814        self.writer.write_raw(tag, elements)
815    }
816
817    /// Write a segment mixing simple and composite data elements within this message.
818    ///
819    /// Delegates to [`Writer::write_elements`] — the general form, and the one
820    /// to reach for when a segment is not uniformly simple or uniformly
821    /// composite.
822    ///
823    /// # Errors
824    ///
825    /// Returns [`EdifactError`] if the underlying writer fails.
826    pub fn write_elements(
827        &mut self,
828        tag: &str,
829        elements: &[DataElement<'_>],
830    ) -> Result<(), EdifactError> {
831        self.writer.write_elements(tag, elements)
832    }
833
834    /// Write a segment from borrowed element/component slices within this message.
835    ///
836    /// Delegates to [`Writer::write_composites`].
837    ///
838    /// # Errors
839    ///
840    /// Returns [`EdifactError`] if the underlying writer fails.
841    pub fn write_composites(
842        &mut self,
843        tag: &str,
844        elements: &[&[&str]],
845    ) -> Result<(), EdifactError> {
846        self.writer.write_composites(tag, elements)
847    }
848
849    /// Write a segment from pre-split, owned element/component data within this message.
850    ///
851    /// Delegates to [`Writer::write_segment_parts`].
852    ///
853    /// # Errors
854    ///
855    /// Returns [`EdifactError`] if the underlying writer fails.
856    pub fn write_segment_parts<E>(&mut self, tag: &str, elements: &[E]) -> Result<(), EdifactError>
857    where
858        E: AsRef<[String]>,
859    {
860        self.writer.write_segment_parts(tag, elements)
861    }
862
863    /// Write a fully-typed segment within this message.
864    ///
865    /// Delegates to [`Writer::write_segment`].
866    pub fn write_segment(&mut self, seg: &Segment<'_>) -> Result<(), EdifactError> {
867        self.writer.write_segment(seg)
868    }
869
870    /// Compute the per-message segment count and write `UNT`, consuming the guard.
871    ///
872    /// The count written into `UNT` DE 0074 includes `UNH`, all content segments,
873    /// and `UNT` itself — matching the EDIFACT standard.
874    ///
875    /// # Errors
876    ///
877    /// Returns [`EdifactError`] if writing the `UNT` segment fails.
878    pub fn finish(mut self) -> Result<(), EdifactError> {
879        self.write_unt()?;
880        self.finished = true;
881        Ok(())
882    }
883
884    fn write_unt(&mut self) -> Result<(), EdifactError> {
885        // Segments since UNH: writer.segment_count - unh_count (content only).
886        // Total = 1 (UNH) + content + 1 (UNT) = content + 2.
887        let count = self.writer.segment_count - self.unh_count + 2;
888        let count_str = count.to_string();
889        self.writer.write_composites(
890            "UNT",
891            &[&[count_str.as_str()], &[self.message_ref.as_str()]],
892        )
893    }
894}
895
896impl<W: Write> Drop for MessageWriter<'_, W> {
897    fn drop(&mut self) {
898        if !self.finished {
899            // Best-effort: write UNT; errors cannot be propagated from drop.
900            let _ = self.write_unt();
901        }
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use crate::model::Element;
909
910    /// A non-default UNA whose delimiters share no byte with the defaults.
911    fn exotic_ssa() -> ServiceStringAdvice {
912        ServiceStringAdvice {
913            component_sep: b'|',
914            element_sep: b'!',
915            decimal_mark: b',',
916            release_char: b'#',
917            repetition_sep: b'*',
918            segment_term: b'~',
919        }
920    }
921
922    #[test]
923    fn unh_composite_uses_the_active_component_separator() {
924        // `begin_message` used to `format!` the S009 composite with a literal
925        // `:`, collapsing it into one component under a custom UNA.
926        let mut buf = Vec::new();
927        {
928            let mut w = Writer::with_una(&mut buf, exotic_ssa()).unwrap();
929            let msg = w
930                .begin_message("1", "ORDERS", "D", "96A", "UN")
931                .expect("UNH");
932            msg.finish().expect("UNT");
933        }
934        let out = String::from_utf8(buf).unwrap();
935        assert!(
936            out.contains("UNH!1!ORDERS|D|96A|UN~"),
937            "S009 must use `|`, got {out}"
938        );
939    }
940
941    #[test]
942    fn round_trips_through_a_custom_una() {
943        // The library must be able to re-read its own output verbatim.
944        let mut buf = Vec::new();
945        {
946            let mut w = Writer::with_una(&mut buf, exotic_ssa()).unwrap();
947            w.begin_interchange("UNOA", "1", "SENDER", "RECEIVER", "200101", "0900", "IC1")
948                .unwrap();
949            let mut msg = w.begin_message("1", "ORDERS", "D", "96A", "UN").unwrap();
950            msg.write_raw("BGM", &["220"]).unwrap();
951            msg.finish().unwrap();
952            w.end_interchange(1, "IC1").unwrap();
953        }
954        let segs: Vec<_> = crate::from_bytes(&buf)
955            .collect::<Result<Vec<_>, _>>()
956            .expect("own output must reparse");
957        let unh = segs.iter().find(|s| s.tag == "UNH").unwrap();
958        assert_eq!(unh.get_element(1).unwrap().get_component(0), Some("ORDERS"));
959        assert_eq!(unh.get_element(1).unwrap().get_component(2), Some("96A"));
960        crate::validate_envelope(&segs).expect("own output must pass envelope validation");
961    }
962
963    #[test]
964    fn finish_unt_counts_only_the_current_message() {
965        // `finish_unt` used the writer-lifetime segment total, so a preceding
966        // UNB inflated DE 0074 and the interchange failed its own validation.
967        let mut buf = Vec::new();
968        {
969            let mut w = Writer::new(&mut buf);
970            w.begin_interchange("UNOA", "1", "S", "R", "200101", "0900", "IC1")
971                .unwrap();
972            w.write_composites("UNH", &[&["1"], &["ORDERS", "D", "96A", "UN"]])
973                .unwrap();
974            w.write_raw("BGM", &["220"]).unwrap();
975            w.finish_unt("1").unwrap();
976        }
977        let out = String::from_utf8(buf).unwrap();
978        // UNH + BGM + UNT == 3
979        assert!(out.contains("UNT+3+1'"), "expected UNT+3, got {out}");
980    }
981
982    #[test]
983    fn repetition_separator_is_escaped_when_declared() {
984        let mut buf = Vec::new();
985        {
986            let mut w = Writer::with_una(
987                &mut buf,
988                ServiceStringAdvice {
989                    repetition_sep: b'*',
990                    ..ServiceStringAdvice::default()
991                },
992            )
993            .unwrap();
994            w.write_composites("FTX", &[&["a*b"]]).unwrap();
995        }
996        let out = String::from_utf8(buf).unwrap();
997        assert!(out.ends_with("FTX+a?*b'"), "rep-sep unescaped in {out}");
998    }
999
1000    #[test]
1001    fn repetition_separator_sentinel_is_not_escaped() {
1002        // Space at UNA position 7 means "not used" and must never be escaped.
1003        let w = Writer::new(std::io::sink());
1004        assert_eq!(w.escape_value("a b"), "a b");
1005    }
1006
1007    #[test]
1008    fn write_composites_escapes_a_literal_component_separator() {
1009        let mut buf = Vec::new();
1010        {
1011            let mut w = Writer::new(&mut buf);
1012            w.write_composites("NAD", &[&["MS"], &["ACME:INC"]])
1013                .unwrap();
1014        }
1015        let segs: Vec<_> = crate::from_bytes(&buf)
1016            .collect::<Result<Vec<_>, _>>()
1017            .unwrap();
1018        // The `:` stays inside the value instead of splitting the element.
1019        assert_eq!(
1020            segs[0].get_element(1).unwrap().get_component(0),
1021            Some("ACME:INC")
1022        );
1023    }
1024
1025    #[test]
1026    fn write_elements_mixes_simple_and_composite() {
1027        let mut buf = Vec::new();
1028        {
1029            let mut w = Writer::new(&mut buf);
1030            w.write_elements(
1031                "NAD",
1032                &[
1033                    DataElement::Simple("MS"),
1034                    DataElement::Composite(&["9900112233445", "", "293"]),
1035                ],
1036            )
1037            .unwrap();
1038        }
1039        assert_eq!(buf, b"NAD+MS+9900112233445::293'");
1040    }
1041
1042    #[test]
1043    fn elements_macro_matches_the_explicit_form() {
1044        let mut macro_buf = Vec::new();
1045        {
1046            let mut w = Writer::new(&mut macro_buf);
1047            w.write_elements("NAD", elements!["MS", ["ACME", "", "9"]])
1048                .unwrap();
1049            w.write_elements("DTM", elements![["137", "20260101", "102"]])
1050                .unwrap();
1051        }
1052        let mut explicit_buf = Vec::new();
1053        {
1054            let mut w = Writer::new(&mut explicit_buf);
1055            w.write_elements(
1056                "NAD",
1057                &[
1058                    DataElement::Simple("MS"),
1059                    DataElement::Composite(&["ACME", "", "9"]),
1060                ],
1061            )
1062            .unwrap();
1063            w.write_elements(
1064                "DTM",
1065                &[DataElement::Composite(&["137", "20260101", "102"])],
1066            )
1067            .unwrap();
1068        }
1069        assert_eq!(macro_buf, explicit_buf);
1070        assert_eq!(macro_buf, b"NAD+MS+ACME::9'DTM+137:20260101:102'");
1071    }
1072
1073    #[test]
1074    fn elements_macro_accepts_arbitrary_expressions() {
1075        // Builders emit runtime values, not literals.  A `tt`-based macro only
1076        // matched single-token entries, so `qualifier.as_str()` failed to parse
1077        // — which is precisely the shape this macro exists for.
1078        let qualifier = String::from("MS");
1079        let gln = "9900112233445";
1080        let dtm: Vec<&str> = vec!["137", "20260101", "102"];
1081
1082        let mut buf = Vec::new();
1083        {
1084            let mut w = Writer::new(&mut buf);
1085            w.write_elements("NAD", elements![qualifier.as_str(), [gln, "", "293"]])
1086                .unwrap();
1087            w.write_elements("DTM", elements![dtm]).unwrap();
1088            w.write_elements("FTX", elements![qualifier]).unwrap();
1089            w.write_elements("UNS", elements![]).unwrap();
1090        }
1091        assert_eq!(
1092            String::from_utf8(buf).unwrap(),
1093            "NAD+MS+9900112233445::293'DTM+137:20260101:102'FTX+MS'UNS'"
1094        );
1095    }
1096
1097    #[test]
1098    fn write_elements_escapes_a_literal_component_separator() {
1099        // The `:` stays inside the value instead of splitting the element —
1100        // the failure mode of pre-joining components into one string.
1101        let mut buf = Vec::new();
1102        {
1103            let mut w = Writer::new(&mut buf);
1104            w.write_elements(
1105                "NAD",
1106                &[DataElement::Simple("MS"), DataElement::Simple("ACME:INC")],
1107            )
1108            .unwrap();
1109        }
1110        let segs: Vec<_> = crate::from_bytes(&buf)
1111            .collect::<Result<Vec<_>, _>>()
1112            .unwrap();
1113        assert_eq!(
1114            segs[0].get_element(1).unwrap().get_component(0),
1115            Some("ACME:INC")
1116        );
1117    }
1118
1119    #[test]
1120    fn write_elements_uses_the_active_component_separator() {
1121        let mut buf = Vec::new();
1122        {
1123            let mut w = Writer::with_una(&mut buf, exotic_ssa()).unwrap();
1124            w.write_elements("DTM", elements![["137", "20260101", "102"]])
1125                .unwrap();
1126        }
1127        let out = String::from_utf8(buf).unwrap();
1128        assert!(
1129            out.ends_with("DTM!137|20260101|102~"),
1130            "expected custom delimiters, got {out}"
1131        );
1132    }
1133
1134    #[test]
1135    fn message_writer_counts_write_elements_segments() {
1136        // `MessageWriter` had no mixed-emit delegate, so callers dropped to the
1137        // raw writer and their segments escaped the UNT DE 0074 count.
1138        let mut buf = Vec::new();
1139        {
1140            let mut w = Writer::new(&mut buf);
1141            let mut msg = w.begin_message("1", "ORDERS", "D", "96A", "UN").unwrap();
1142            msg.write_elements("NAD", elements!["MS", ["ACME", "", "9"]])
1143                .unwrap();
1144            msg.write_composites("DTM", &[&["137", "20260101", "102"]])
1145                .unwrap();
1146            msg.finish().unwrap();
1147        }
1148        let out = String::from_utf8(buf).unwrap();
1149        // UNH + NAD + DTM + UNT == 4
1150        assert!(out.contains("UNT+4+1'"), "expected UNT+4, got {out}");
1151    }
1152
1153    #[test]
1154    fn write_segment_records_unh_for_the_unt_count() {
1155        // `write_segment` and `write_segment_parts` did not record the UNH
1156        // marker, so `finish_unt` fell back to the writer-lifetime total and
1157        // DE 0074 came out inflated by every preceding interchange segment.
1158        let mut buf = Vec::new();
1159        {
1160            let mut w = Writer::new(&mut buf);
1161            w.begin_interchange("UNOA", "1", "S", "R", "200101", "0900", "IC1")
1162                .unwrap();
1163            w.write_segment(&Segment::new(
1164                "UNH",
1165                vec![
1166                    Element::of(&["1"]),
1167                    Element::of(&["ORDERS", "D", "96A", "UN"]),
1168                ],
1169            ))
1170            .unwrap();
1171            w.write_segment(&Segment::new("BGM", vec![Element::of(&["220"])]))
1172                .unwrap();
1173            w.finish_unt("1").unwrap();
1174        }
1175        let out = String::from_utf8(buf).unwrap();
1176        // UNH + BGM + UNT == 3, not 4 (which would count the UNB).
1177        assert!(out.contains("UNT+3+1'"), "expected UNT+3, got {out}");
1178    }
1179
1180    #[test]
1181    fn write_segment_parts_records_unh_for_the_unt_count() {
1182        let mut buf = Vec::new();
1183        {
1184            let mut w = Writer::new(&mut buf);
1185            w.begin_interchange("UNOA", "1", "S", "R", "200101", "0900", "IC1")
1186                .unwrap();
1187            w.write_segment_parts(
1188                "UNH",
1189                &[
1190                    vec!["1".to_owned()],
1191                    vec![
1192                        "ORDERS".to_owned(),
1193                        "D".to_owned(),
1194                        "96A".to_owned(),
1195                        "UN".to_owned(),
1196                    ],
1197                ],
1198            )
1199            .unwrap();
1200            w.write_raw("BGM", &["220"]).unwrap();
1201            w.finish_unt("1").unwrap();
1202        }
1203        let out = String::from_utf8(buf).unwrap();
1204        assert!(out.contains("UNT+3+1'"), "expected UNT+3, got {out}");
1205    }
1206
1207    #[test]
1208    fn escape_value_handles_multi_byte_text() {
1209        // The old implementation assembled a `Vec<u8>` and re-validated it with
1210        // an `expect`.  Escaping around non-ASCII text is the case that made
1211        // that conversion look fallible in the first place.
1212        let w = Writer::new(std::io::sink());
1213        assert_eq!(w.escape_value("Grüße+Köln"), "Grüße?+Köln");
1214        assert_eq!(w.escape_value("Grüße"), "Grüße");
1215        assert!(matches!(w.escape_value("plain"), Cow::Borrowed("plain")));
1216    }
1217
1218    #[test]
1219    fn write_and_parse_simple_segment() {
1220        let segs: Vec<Segment<'static>> = vec![Segment::new(
1221            "BGM",
1222            vec![Element::of(&["220"]), Element::of(&["ORDER123"])],
1223        )];
1224        let bytes = crate::segments_to_bytes(&segs).unwrap();
1225        let s = std::str::from_utf8(&bytes).unwrap();
1226        assert!(s.starts_with("BGM+220+ORDER123'"));
1227    }
1228
1229    #[test]
1230    fn release_char_escaped() {
1231        let segs: Vec<Segment<'static>> = vec![Segment::new(
1232            "FTX",
1233            vec![Element::of(&["value+with+delimiters"])],
1234        )];
1235        let bytes = crate::segments_to_bytes(&segs).unwrap();
1236        let s = std::str::from_utf8(&bytes).unwrap();
1237        // The `+` in the value must be escaped as `?+`
1238        assert!(s.contains("?+"), "escape missing: {s}");
1239    }
1240
1241    #[test]
1242    fn round_trip_preserves_values() {
1243        let segs: Vec<Segment<'static>> = vec![
1244            Segment::new(
1245                "UNB",
1246                vec![
1247                    Element::of(&["UNOA", "1"]),
1248                    Element::of(&["SENDER"]),
1249                    Element::of(&["RECEIVER"]),
1250                ],
1251            ),
1252            Segment::new("UNZ", vec![Element::of(&["0"]), Element::of(&["1"])]),
1253        ];
1254        let bytes = crate::segments_to_bytes(&segs).unwrap();
1255        let rt: Vec<crate::OwnedSegment> = crate::parser::from_reader(std::io::Cursor::new(&bytes))
1256            .expect("round-trip parse failed");
1257        assert_eq!(rt[0].tag, "UNB");
1258        assert_eq!(rt[0].as_borrowed().element_str(0), Some("UNOA"));
1259        assert_eq!(rt[1].tag, "UNZ");
1260    }
1261
1262    /// Verify that `Writer::with_una` uses the configured delimiters throughout,
1263    /// and that `write_segment_parts` (the delimiter-agnostic API) produces correct
1264    /// component separators even with a non-default UNA.
1265    #[test]
1266    fn with_una_non_default_delimiters() {
1267        use crate::tokenizer::ServiceStringAdvice;
1268
1269        // Custom UNA: comp_sep=|  elem_sep=!  esc=?  dec_mark=,  rep_sep=*  seg_term=~
1270        let ssa = ServiceStringAdvice {
1271            component_sep: b'|',
1272            element_sep: b'!',
1273            release_char: b'?',
1274            decimal_mark: b',',
1275            repetition_sep: b'*',
1276            segment_term: b'~',
1277        };
1278
1279        let buf = Vec::new();
1280        let mut writer = Writer::with_una(buf, ssa).expect("writer creation failed");
1281
1282        // write_segment_parts: pre-split; no hard-coded `:` in element strings
1283        writer
1284            .write_segment_parts(
1285                "BGM",
1286                &[
1287                    vec!["220".to_owned(), "SUB1".to_owned()],
1288                    vec!["PO1".to_owned()],
1289                ],
1290            )
1291            .expect("write failed");
1292
1293        let out = writer.finish().expect("finish failed");
1294        let s = std::str::from_utf8(&out).unwrap();
1295
1296        // Output must use `!` as element separator, `|` as component separator, `~` as terminator.
1297        // The writer also emits a UNA header when with_una is used.
1298        assert!(s.contains("BGM"), "BGM segment missing: {s}");
1299        // Slice after UNA so assertions target segment output, not UNA header bytes.
1300        let after_una = s.find("BGM").map(|i| &s[i..]).unwrap_or(s);
1301        assert!(
1302            after_una.contains('!'),
1303            "missing element sep in segment: {after_una}"
1304        );
1305        assert!(
1306            after_una.contains('|'),
1307            "missing component sep in segment: {after_una}"
1308        );
1309        assert!(
1310            after_una.ends_with('~'),
1311            "missing segment term in segment: {after_una}"
1312        );
1313        // Decimal mark appears in the UNA header (no decimal-bearing values in this segment).
1314        assert!(s.contains(','), "missing decimal mark in UNA: {s}");
1315        assert!(!s.contains('+'), "default element sep leaked: {s}");
1316        assert!(!s.contains(':'), "default component sep leaked: {s}");
1317        // segment_term '~' is not the default; ensure no default ' leaks (UNA itself aside)
1318        assert!(
1319            !after_una.contains('\''),
1320            "default segment term leaked after UNA: {after_una}"
1321        );
1322    }
1323}