Skip to main content

freeswitch_types/variables/
esl_headers.rs

1//! [`EslHeaders`] — a flat header store that understands FreeSWITCH's
2//! transport encodings.
3//!
4//! FreeSWITCH's ESL wire format carries headers and channel variables in the
5//! same flat key-value namespace, but with two transport quirks that plain
6//! RFC-SIP parsers don't account for:
7//!
8//! - **ARRAY encoding** — repeating SIP headers arrive as
9//!   `ARRAY::value1|:value2|:value3` (see [`EslArray`]).
10//! - **Bracket wrapping** — some log-sourced headers arrive as `[value]`.
11//!
12//! Routing those values through the default [`SipHeaderLookup`] methods
13//! produces parse errors because the string doesn't match RFC syntax.
14//! [`EslHeaders`] wraps an [`IndexMap<String, String>`] and overrides the
15//! relevant `SipHeaderLookup` methods to strip both quirks before parsing.
16//! The design-rationale doc §"EslHeaders: making the transport boundary
17//! visible" explains the layering.
18
19use indexmap::IndexMap;
20use sip_header::{HistoryInfo, HistoryInfoError, SipHeaderLookup, UriInfo, UriInfoError};
21
22use crate::headers::{case_alias_key, normalize_header_key};
23use crate::lookup::{variable_key, HeaderLookup};
24use crate::variables::{EslArray, EslArrayError, MAX_ARRAY_ITEMS};
25
26/// A flat header store that decodes FreeSWITCH ARRAY and bracket encoding
27/// when answering typed SIP header queries.
28///
29/// Construct with [`EslHeaders::new`] or [`EslHeaders::from_map`]. Use it
30/// anywhere a [`HeaderLookup`] or [`SipHeaderLookup`] implementor is
31/// expected:
32///
33/// ```
34/// use freeswitch_types::{EslHeaders, HeaderLookup};
35/// use freeswitch_types::sip_header::SipHeaderLookup;
36///
37/// let mut h = EslHeaders::new();
38/// h.insert("Unique-ID", "abc-123");
39/// h.insert("Call-Info", "ARRAY::<sip:a@example.com>;purpose=icon|:<sip:b@example.com>");
40///
41/// assert_eq!(h.header_str("Unique-ID"), Some("abc-123"));
42/// let ci = h.call_info().unwrap().unwrap();
43/// assert_eq!(ci.entries().len(), 2);
44/// ```
45///
46/// `HeaderLookup` delegates straight to the map; `SipHeaderLookup` methods
47/// that parse RFC-structured values (`call_info`, `history_info`, and any
48/// future multi-value parsers) first peel the FreeSWITCH encoding and then
49/// hand pre-split entries to `sip-header`. Non-parsing lookups
50/// (`sip_header_str`, `sip_header`) return the raw stored value untouched —
51/// the caller sees exactly what FreeSWITCH put on the wire.
52///
53/// Every write normalizes its key with
54/// [`normalize_header_key`](crate::normalize_header_key), so a payload
55/// spelling one header two ways collapses to one entry, and every read falls
56/// back to a lowercase alias, so a query in any casing resolves. Keys
57/// containing `_` are exempt from both: their suffix carries SIP wire casing.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize), serde(transparent))]
60pub struct EslHeaders {
61    map: IndexMap<String, String>,
62    /// Lowercase alias to canonical key, for the case-insensitive read path.
63    /// Derived from `map`, so it is rebuilt rather than serialized.
64    #[cfg_attr(feature = "serde", serde(skip))]
65    aliases: IndexMap<String, String>,
66}
67
68impl EslHeaders {
69    /// Create an empty store.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Wrap an existing map, normalizing every key.
75    pub fn from_map(map: IndexMap<String, String>) -> Self {
76        map.into_iter()
77            .collect()
78    }
79
80    /// Access the underlying map, keyed canonically.
81    pub fn as_map(&self) -> &IndexMap<String, String> {
82        &self.map
83    }
84
85    /// Consume and return the underlying map, keyed canonically.
86    pub fn into_map(self) -> IndexMap<String, String> {
87        self.map
88    }
89
90    /// Insert a header under its canonical key, replacing any entry there.
91    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
92        let key = normalize_header_key(&key.into());
93        if let Some(alias) = case_alias_key(&key) {
94            self.aliases
95                .insert(alias, key.clone());
96        }
97        self.map
98            .insert(key, value.into());
99    }
100
101    /// Remove a header, by canonical key or by any other casing of it.
102    pub fn remove(&mut self, key: &str) -> Option<String> {
103        let canonical = if self
104            .map
105            .contains_key(key)
106        {
107            key.to_string()
108        } else {
109            let alias = case_alias_key(key)?;
110            self.aliases
111                .get(&alias)?
112                .clone()
113        };
114        if let Some(alias) = case_alias_key(&canonical) {
115            self.aliases
116                .shift_remove(&alias);
117        }
118        self.map
119            .shift_remove(&canonical)
120    }
121
122    /// Number of entries.
123    pub fn len(&self) -> usize {
124        self.map
125            .len()
126    }
127
128    /// `true` if there are no entries.
129    pub fn is_empty(&self) -> bool {
130        self.map
131            .is_empty()
132    }
133
134    fn get(&self, name: &str) -> Option<&str> {
135        if let Some(value) = self
136            .map
137            .get(name)
138        {
139            return Some(value.as_str());
140        }
141        self.aliases
142            .get(&case_alias_key(name)?)
143            .and_then(|canonical| {
144                self.map
145                    .get(canonical)
146            })
147            .map(|s| s.as_str())
148    }
149}
150
151impl<K: Into<String>, V: Into<String>> FromIterator<(K, V)> for EslHeaders {
152    fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
153        let mut headers = Self::new();
154        headers.extend(iter);
155        headers
156    }
157}
158
159impl<K: Into<String>, V: Into<String>> Extend<(K, V)> for EslHeaders {
160    fn extend<I: IntoIterator<Item = (K, V)>>(&mut self, iter: I) {
161        for (key, value) in iter {
162            self.insert(key, value);
163        }
164    }
165}
166
167impl From<IndexMap<String, String>> for EslHeaders {
168    fn from(map: IndexMap<String, String>) -> Self {
169        Self::from_map(map)
170    }
171}
172
173#[cfg(feature = "serde")]
174impl<'de> serde::Deserialize<'de> for EslHeaders {
175    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176    where
177        D: serde::Deserializer<'de>,
178    {
179        Ok(Self::from_map(IndexMap::deserialize(deserializer)?))
180    }
181}
182
183/// Strip a single pair of outer `[...]` brackets from FreeSWITCH log-derived
184/// header values. If the value is not bracket-wrapped, returns it unchanged.
185fn strip_brackets(s: &str) -> &str {
186    if let Some(inner) = s.strip_prefix('[') {
187        if let Some(inner) = inner.strip_suffix(']') {
188            return inner;
189        }
190    }
191    s
192}
193
194/// The one splitter every list-valued lookup here goes through: bracket
195/// unwrapping, then `ARRAY::` splitting, falling back to the RFC comma split.
196fn split_entries_ref(value: &str) -> Result<Vec<&str>, EslArrayError> {
197    let value = strip_brackets(value);
198    match value.strip_prefix(EslArray::PREFIX) {
199        Some(body) => {
200            let items: Vec<&str> = body
201                .split(EslArray::SEPARATOR)
202                .collect();
203            if items.len() > MAX_ARRAY_ITEMS {
204                return Err(EslArrayError::TooManyItems {
205                    count: items.len(),
206                    max: MAX_ARRAY_ITEMS,
207                });
208            }
209            Ok(items)
210        }
211        None => {
212            let value = value.trim();
213            if value.is_empty() {
214                Ok(Vec::new())
215            } else {
216                Ok(sip_header::split_comma_entries(value))
217            }
218        }
219    }
220}
221
222/// A structurally invalid ESL encoding hands the value back whole, so the RFC
223/// parser reports it rather than this layer dropping entries.
224fn entries_or_whole(value: &str) -> Vec<&str> {
225    split_entries_ref(value).unwrap_or_else(|_| vec![value])
226}
227
228impl EslHeaders {
229    /// Parse a FreeSWITCH-transported SIP URI-list value into typed [`UriInfo`]
230    /// entries, handling both `ARRAY::` encoding and bracket wrapping.
231    ///
232    /// Use this when you hold a *raw* value — e.g. the `sip_call_info` /
233    /// `sip_alert_info` channel variable fetched over ESL — rather than a
234    /// populated [`EslHeaders`]. It accepts any of the forms FreeSWITCH emits:
235    ///
236    /// - **Single RFC entry**: `<sip:a@example.test>;purpose=emergency-CallId`
237    /// - **ARRAY encoding**: `ARRAY::<sip:a@example.test>;purpose=icon|:<sip:b@example.test>`
238    /// - **Bracket-wrapped**: `[<sip:a@example.test>;purpose=icon]`
239    ///
240    /// This is the same decoding the [`call_info`](SipHeaderLookup::call_info)
241    /// and [`alert_info`](SipHeaderLookup::alert_info) methods apply; iterate
242    /// the result via `.entries()`.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`UriInfoError`] if the value is malformed or if the `ARRAY::`
247    /// structure is invalid. Structural `EslArrayError` cases (e.g.
248    /// `TooManyItems`) are surfaced as [`UriInfoError::Malformed`] carrying
249    /// the cause.
250    ///
251    /// # Example
252    ///
253    /// ```
254    /// use freeswitch_types::EslHeaders;
255    ///
256    /// let value = "ARRAY::<urn:emergency:uid:callid:bcf.test>;purpose=emergency-CallId\
257    ///              |:<urn:emergency:uid:incidentid:bcf.test>;purpose=emergency-IncidentId";
258    /// let info = EslHeaders::parse_uri_info(value).unwrap();
259    /// assert_eq!(info.entries().len(), 2);
260    /// ```
261    pub fn parse_uri_info(value: &str) -> Result<UriInfo, UriInfoError> {
262        let entries =
263            Self::split_entries(value).map_err(|e| UriInfoError::Malformed(e.to_string()))?;
264        UriInfo::from_entries(
265            entries
266                .iter()
267                .map(String::as_str),
268        )
269    }
270
271    /// Split one comma-list SIP header value held as an ESL variable into its
272    /// entries, applying the same decoding as [`parse_uri_info`](Self::parse_uri_info)
273    /// and [`parse_history_info`](Self::parse_history_info): bracket unwrapping,
274    /// then `ARRAY::` splitting, falling back to the RFC comma split.
275    ///
276    /// Only for headers that are lists (`Call-Info`, `Alert-Info`,
277    /// `History-Info`, `Diversion`, …). A header whose value legitimately
278    /// contains commas without being a list — `Date`, `Subject`, `User-Agent` —
279    /// must not go through it.
280    ///
281    /// # Errors
282    ///
283    /// A missing `ARRAY::` prefix is the RFC form, not an error. Every other
284    /// [`EslArrayError`] is a structural fault in the ESL encoding and is
285    /// returned as-is. An empty or whitespace-only value yields no entries,
286    /// which the typed parsers turn into their own `Empty` error.
287    ///
288    /// ```
289    /// use freeswitch_types::EslHeaders;
290    ///
291    /// let entries =
292    ///     EslHeaders::split_entries("ARRAY::<sip:a@example.test>|:<sip:b@example.test>").unwrap();
293    /// assert_eq!(entries, ["<sip:a@example.test>", "<sip:b@example.test>"]);
294    /// ```
295    pub fn split_entries(value: &str) -> Result<Vec<String>, EslArrayError> {
296        Ok(split_entries_ref(value)?
297            .into_iter()
298            .map(str::to_string)
299            .collect())
300    }
301
302    /// Entries of a header `sip-header` marks multi-valued, for
303    /// [`sip_header_all_str`](SipHeaderLookup::sip_header_all_str). Any other
304    /// header answers with its value whole: only a list may be split.
305    #[doc(hidden)]
306    pub fn split_multi_value<'a>(value: Option<&'a str>, name: &str) -> Vec<&'a str> {
307        let Some(value) = value else {
308            return Vec::new();
309        };
310        let multi = name
311            .parse::<sip_header::SipHeader>()
312            .is_ok_and(|h| h.is_multi_valued());
313        if multi {
314            entries_or_whole(value)
315        } else {
316            vec![value]
317        }
318    }
319
320    /// Parse a FreeSWITCH-transported `History-Info` value into a typed
321    /// [`HistoryInfo`], handling both `ARRAY::` encoding and bracket wrapping.
322    ///
323    /// The raw-value counterpart to [`history_info`](SipHeaderLookup::history_info).
324    ///
325    /// # Errors
326    ///
327    /// Structural `EslArrayError` cases (e.g. `TooManyItems`) are surfaced as
328    /// [`HistoryInfoError::Malformed`] carrying the cause.
329    pub fn parse_history_info(value: &str) -> Result<HistoryInfo, HistoryInfoError> {
330        let entries =
331            Self::split_entries(value).map_err(|e| HistoryInfoError::Malformed(e.to_string()))?;
332        HistoryInfo::from_entries(
333            entries
334                .iter()
335                .map(String::as_str),
336        )
337    }
338}
339
340/// Internal: emits the one [`SipHeaderLookup`] override that peels
341/// FreeSWITCH's ARRAY and bracket encoding, `sip_header_all_str`, which every
342/// list-valued default routes through. Invoke inside an
343/// `impl SipHeaderLookup for T` block. Not part of the stable API.
344#[doc(hidden)]
345#[macro_export]
346macro_rules! esl_sip_header_overrides {
347    () => {
348        fn sip_header_all_str<'a>(&'a self, name: &str) -> Vec<&'a str> {
349            $crate::variables::EslHeaders::split_multi_value(self.sip_header_str(name), name)
350        }
351    };
352}
353
354impl SipHeaderLookup for EslHeaders {
355    fn sip_header_str(&self, name: &str) -> Option<&str> {
356        self.get(name)
357    }
358
359    crate::esl_sip_header_overrides!();
360}
361
362impl HeaderLookup for EslHeaders {
363    fn header_str(&self, name: &str) -> Option<&str> {
364        self.get(name)
365    }
366
367    fn variable_str(&self, name: &str) -> Option<&str> {
368        self.get(&variable_key(name))
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::headers::EventHeader;
376
377    #[test]
378    fn every_write_path_normalizes_the_key() {
379        let mut inserted = EslHeaders::new();
380        inserted.insert("unique-id", "abc-123");
381
382        let mut extended = EslHeaders::new();
383        extended.extend([("unique-id", "abc-123")]);
384
385        let map: IndexMap<String, String> = [("unique-id".to_string(), "abc-123".to_string())]
386            .into_iter()
387            .collect();
388
389        for h in [
390            inserted,
391            extended,
392            [("unique-id", "abc-123")]
393                .into_iter()
394                .collect(),
395            EslHeaders::from_map(map.clone()),
396            EslHeaders::from(map),
397        ] {
398            assert_eq!(
399                h.as_map()
400                    .keys()
401                    .collect::<Vec<_>>(),
402                vec!["Unique-ID"]
403            );
404            assert_eq!(h.unique_id(), Some("abc-123"));
405        }
406    }
407
408    #[test]
409    fn lookup_resolves_a_non_canonical_spelling() {
410        let mut h = EslHeaders::new();
411        h.insert("Unique-ID", "abc-123");
412        h.insert("Call-Info", "<sip:a@example.test>;purpose=icon");
413        assert_eq!(h.header_str("unique-id"), Some("abc-123"));
414        assert_eq!(h.header_str("UNIQUE-ID"), Some("abc-123"));
415        assert_eq!(
416            h.sip_header_str("call-info"),
417            Some("<sip:a@example.test>;purpose=icon")
418        );
419    }
420
421    #[test]
422    fn underscore_keys_keep_their_wire_casing() {
423        let mut h = EslHeaders::new();
424        h.insert("variable_sip_h_X-My-CUSTOM-Header", "val");
425        assert_eq!(
426            h.header_str("variable_sip_h_X-My-CUSTOM-Header"),
427            Some("val")
428        );
429        assert_eq!(h.header_str("variable_sip_h_x-my-custom-header"), None);
430    }
431
432    #[test]
433    fn two_spellings_of_one_header_are_one_entry() {
434        let mut h = EslHeaders::new();
435        h.insert("Channel-Read-Codec-Bit-Rate", "first");
436        h.insert("channel-read-codec-bit-rate", "second");
437        assert_eq!(h.len(), 1);
438        assert_eq!(h.header_str("Channel-Read-Codec-Bit-Rate"), Some("second"));
439    }
440
441    #[test]
442    fn remove_accepts_a_non_canonical_spelling() {
443        let mut h = EslHeaders::new();
444        h.insert("Unique-ID", "abc-123");
445        assert_eq!(h.remove("unique-id"), Some("abc-123".to_string()));
446        assert!(h.is_empty());
447        assert_eq!(h.header_str("Unique-ID"), None);
448        assert_eq!(h.remove("unique-id"), None);
449    }
450
451    #[test]
452    fn remove_by_canonical_key_drops_the_alias_too() {
453        let mut h = EslHeaders::new();
454        h.insert("unique-id", "abc-123");
455        assert_eq!(h.remove("Unique-ID"), Some("abc-123".to_string()));
456        assert_eq!(h.header_str("unique-id"), None);
457    }
458
459    #[cfg(feature = "serde")]
460    #[test]
461    fn deserialization_normalizes_like_the_wire_does() {
462        let json = r#"{"unique-id":"abc-123","variable_sip_call_id":"call-1"}"#;
463        let h: EslHeaders = serde_json::from_str(json).expect("map payload");
464        assert_eq!(h.unique_id(), Some("abc-123"));
465        assert_eq!(h.variable_str("sip_call_id"), Some("call-1"));
466        assert_eq!(
467            h.as_map()
468                .keys()
469                .collect::<Vec<_>>(),
470            vec!["Unique-ID", "variable_sip_call_id"]
471        );
472
473        let round_tripped: EslHeaders =
474            serde_json::from_str(&serde_json::to_string(&h).expect("serialize"))
475                .expect("round trip");
476        assert_eq!(round_tripped, h);
477    }
478
479    #[test]
480    fn array_encoding_decodes_for_every_multi_value_header() {
481        let mut h = EslHeaders::new();
482        h.insert(
483            "P-Asserted-Identity",
484            "ARRAY::<sip:alice@example.test>|:<tel:+15551234567>",
485        );
486        h.insert(
487            "Error-Info",
488            "ARRAY::<sip:busy@example.test>|:<http://example.test/why.html>",
489        );
490        h.insert(
491            "Contact",
492            "ARRAY::<sip:a@192.0.2.1>;expires=60|:<sip:b@192.0.2.2>",
493        );
494        h.insert(
495            "Via",
496            "ARRAY::SIP/2.0/UDP 192.0.2.1:5060;branch=z9hG4bK1|:SIP/2.0/TCP 192.0.2.2;branch=z9hG4bK2",
497        );
498        h.insert(
499            "Warning",
500            "ARRAY::370 192.0.2.1 \"Insufficient bandwidth\"|:399 192.0.2.2 \"Misc, warning\"",
501        );
502        h.insert("Accept", "ARRAY::application/sdp|:text/plain;q=0.5");
503
504        assert_eq!(
505            h.via()
506                .expect("ARRAY value must decode")
507                .expect("header is present")
508                .len(),
509            2
510        );
511        assert_eq!(
512            h.warning()
513                .expect("ARRAY value must decode")
514                .expect("header is present")
515                .len(),
516            2
517        );
518        assert_eq!(
519            h.accept()
520                .expect("ARRAY value must decode")
521                .expect("header is present")
522                .len(),
523            2
524        );
525        assert_eq!(
526            h.p_asserted_identity()
527                .expect("ARRAY value must decode")
528                .len(),
529            2
530        );
531        assert_eq!(
532            h.error_info()
533                .expect("ARRAY value must decode")
534                .expect("header is present")
535                .entries()
536                .len(),
537            2
538        );
539        assert_eq!(
540            h.contact()
541                .expect("ARRAY value must decode")
542                .len(),
543            2
544        );
545    }
546
547    #[test]
548    fn a_single_valued_header_keeps_its_commas() {
549        let mut h = EslHeaders::new();
550        h.insert("Subject", "one, two");
551        assert_eq!(h.sip_header_all_str("Subject"), vec!["one, two"]);
552    }
553
554    #[test]
555    fn header_str_passthrough() {
556        let mut h = EslHeaders::new();
557        h.insert("Unique-ID", "abc-123");
558        assert_eq!(h.header_str("Unique-ID"), Some("abc-123"));
559    }
560
561    #[test]
562    fn variable_str_prepends_variable_prefix() {
563        let mut h = EslHeaders::new();
564        h.insert("variable_sip_call_id", "call-1");
565        assert_eq!(h.variable_str("sip_call_id"), Some("call-1"));
566        assert_eq!(h.variable_str("missing"), None);
567    }
568
569    /// The stored-header entry point and the raw-value one must agree, so
570    /// every form runs through both.
571    #[test]
572    fn both_entry_points_decode_every_transport_form() {
573        let forms = [
574            (
575                "rfc",
576                "<sip:alice@example.test>;purpose=icon",
577                vec![Some("icon")],
578            ),
579            (
580                "array",
581                "ARRAY::<sip:a@example.test>;purpose=icon|:<sip:b@example.test>;purpose=info",
582                vec![Some("icon"), Some("info")],
583            ),
584            (
585                "bracketed",
586                "[<sip:alice@example.test>;purpose=icon]",
587                vec![Some("icon")],
588            ),
589            (
590                "bracketed array",
591                "[ARRAY::<sip:a@example.test>;purpose=icon|:<sip:b@example.test>]",
592                vec![Some("icon"), None],
593            ),
594        ];
595
596        for (label, value, purposes) in forms {
597            let mut h = EslHeaders::new();
598            h.insert("Call-Info", value);
599            let from_header = h
600                .call_info()
601                .unwrap_or_else(|e| panic!("{label}: {e}"))
602                .unwrap_or_else(|| panic!("{label}: header is present"));
603            let from_value = EslHeaders::parse_uri_info(value)
604                .unwrap_or_else(|e| panic!("{label} as a raw value: {e}"));
605            assert_eq!(from_header, from_value, "{label}");
606            assert_eq!(
607                from_header
608                    .entries()
609                    .iter()
610                    .map(|e| e.purpose())
611                    .collect::<Vec<_>>(),
612                purposes,
613                "{label}"
614            );
615        }
616    }
617
618    #[test]
619    fn call_info_absent_is_ok_none() {
620        let h = EslHeaders::new();
621        assert!(h
622            .call_info()
623            .unwrap()
624            .is_none());
625    }
626
627    #[test]
628    fn history_info_array_encoding() {
629        let mut h = EslHeaders::new();
630        h.insert(
631            "History-Info",
632            "ARRAY::<sip:a@example.com>;index=1|:<sip:b@example.com>;index=1.1",
633        );
634        let hi = h
635            .history_info()
636            .unwrap()
637            .expect("present");
638        assert_eq!(
639            hi.entries()
640                .len(),
641            2
642        );
643    }
644
645    #[test]
646    fn header_lookup_typed_accessors() {
647        let mut h = EslHeaders::new();
648        h.insert(EventHeader::UniqueId.as_str(), "uuid-1");
649        h.insert(EventHeader::ChannelName.as_str(), "sofia/a/b");
650        assert_eq!(h.unique_id(), Some("uuid-1"));
651        assert_eq!(h.channel_name(), Some("sofia/a/b"));
652    }
653
654    #[test]
655    fn parse_uri_info_empty_value() {
656        assert!(EslHeaders::parse_uri_info("").is_err());
657    }
658
659    #[test]
660    fn parse_uri_info_malformed_no_panic() {
661        // sip-header's UriInfo is lenient: an addr-spec needs no angle brackets.
662        let info = EslHeaders::parse_uri_info("sip:bare@example.test").expect("lenient parse");
663        assert_eq!(
664            info.entries()
665                .len(),
666            1
667        );
668    }
669
670    #[test]
671    fn parse_uri_info_structural_failure_is_malformed() {
672        let value = format!(
673            "ARRAY::{}",
674            vec!["<sip:a@example.com>"; crate::variables::MAX_ARRAY_ITEMS + 1].join("|:")
675        );
676        let err = EslHeaders::parse_uri_info(&value).expect_err("over-limit array must fail");
677        assert!(matches!(err, UriInfoError::Malformed(_)), "got {err:?}");
678    }
679
680    #[test]
681    fn parse_history_info_structural_failure_is_malformed() {
682        let value = format!(
683            "ARRAY::{}",
684            vec!["<sip:a@example.com>;index=1"; crate::variables::MAX_ARRAY_ITEMS + 1].join("|:")
685        );
686        let err = EslHeaders::parse_history_info(&value).expect_err("over-limit array must fail");
687        assert!(matches!(err, HistoryInfoError::Malformed(_)), "got {err:?}");
688    }
689
690    #[test]
691    fn collects_from_key_value_pairs() {
692        let h: EslHeaders = [
693            ("Unique-ID", "uuid-1"),
694            ("Caller-Destination-Number", "911"),
695        ]
696        .into_iter()
697        .collect();
698        assert_eq!(h.header_str("Unique-ID"), Some("uuid-1"));
699    }
700
701    #[test]
702    fn extend_adds_pairs() {
703        let mut h = EslHeaders::new();
704        h.insert("Unique-ID", "uuid-1");
705        h.extend([("Channel-Name", "sofia/a/b")]);
706        assert_eq!(h.len(), 2);
707    }
708
709    #[test]
710    fn split_entries_array_form() {
711        let entries =
712            EslHeaders::split_entries("ARRAY::<sip:a@example.test>|:<sip:b@example.test>")
713                .expect("ARRAY form");
714        assert_eq!(entries, ["<sip:a@example.test>", "<sip:b@example.test>"]);
715    }
716
717    #[test]
718    fn split_entries_rfc_comma_form() {
719        let entries = EslHeaders::split_entries(
720            "<sip:a@example.test>;text=\"one, two\",<sip:b@example.test>",
721        )
722        .expect("RFC form");
723        assert_eq!(entries.len(), 2);
724        assert_eq!(entries[0], "<sip:a@example.test>;text=\"one, two\"");
725    }
726
727    #[test]
728    fn split_entries_bracket_wrapped() {
729        let entries =
730            EslHeaders::split_entries("[<sip:a@example.test>;purpose=icon]").expect("bracket form");
731        assert_eq!(entries, ["<sip:a@example.test>;purpose=icon"]);
732    }
733
734    #[test]
735    fn split_entries_blank_value_yields_no_entries() {
736        let entries = EslHeaders::split_entries("  ").expect("blank value");
737        assert!(entries.is_empty());
738    }
739
740    #[test]
741    fn parse_history_info_blank_value_is_empty_error() {
742        let err = EslHeaders::parse_history_info("  ").expect_err("blank value must fail");
743        assert!(matches!(err, HistoryInfoError::Empty), "got {err:?}");
744    }
745
746    #[test]
747    fn split_entries_too_many_items_is_err() {
748        let value = format!(
749            "ARRAY::{}",
750            vec!["<sip:a@example.test>"; crate::variables::MAX_ARRAY_ITEMS + 1].join("|:")
751        );
752        let err = EslHeaders::split_entries(&value).expect_err("over-limit array must fail");
753        assert!(
754            matches!(err, EslArrayError::TooManyItems { .. }),
755            "got {err:?}"
756        );
757    }
758
759    #[test]
760    fn parse_history_info_array_form() {
761        let value = "ARRAY::<sip:a@example.com>;index=1|:<sip:b@example.com>;index=1.1";
762        let info = EslHeaders::parse_history_info(value).expect("parse ARRAY form");
763        assert_eq!(
764            info.entries()
765                .len(),
766            2
767        );
768    }
769}