Skip to main content

freeswitch_types/commands/
bridge.rs

1//! Bridge dial string builder for multi-endpoint bridge commands.
2//!
3//! Supports simultaneous ring (`,`) and sequential failover (`|`)
4//! with per-endpoint channel variables and global default variables.
5
6use std::fmt;
7use std::str::FromStr;
8
9use super::endpoint::{DialString, Endpoint};
10use super::originate::OriginateError;
11use super::variables::{
12    installed_variables, read_error, BlockParse, DialStringCarrier, DialStringTarget, Variables,
13};
14use crate::switch_passes::brackets::Block;
15use crate::switch_passes::originate_legs::Leg;
16use crate::switch_passes::pipeline;
17
18/// A bridge dial string is the argument of a dialplan application, which
19/// receives it whole, so it renders and parses one escaping level shallower
20/// than the [`DialStringCarrier::EslApi`] default the endpoint types use on
21/// their own.
22const CARRIER: DialStringCarrier = DialStringCarrier::Dialplan;
23
24/// Typed bridge dial string.
25///
26/// Format: `{global_vars}[ep1_vars]ep1,[ep2_vars]ep2|[ep3_vars]ep3`
27///
28/// - `,` separates endpoints rung simultaneously (within a group)
29/// - `|` separates groups tried sequentially (failover)
30/// - Each endpoint may have channel-scope `[variables]`
31/// - Global `{variables}` apply to all endpoints
32#[derive(Debug, Clone, PartialEq, Eq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[cfg_attr(feature = "serde", serde(try_from = "config::BridgeDialString"))]
35pub struct BridgeDialString {
36    #[cfg_attr(
37        feature = "serde",
38        serde(default, skip_serializing_if = "Option::is_none")
39    )]
40    variables: Option<Variables>,
41    groups: Vec<Vec<Endpoint>>,
42}
43
44impl BridgeDialString {
45    /// Create a new bridge dial string with the given failover groups.
46    pub fn new(groups: Vec<Vec<Endpoint>>) -> Self {
47        Self {
48            variables: None,
49            groups,
50        }
51    }
52
53    /// Set global default-scope variables.
54    pub fn with_variables(mut self, variables: Variables) -> Self {
55        self.variables = Some(variables);
56        self
57    }
58
59    /// Default-scope variables applied to all endpoints.
60    pub fn variables(&self) -> Option<&Variables> {
61        self.variables
62            .as_ref()
63    }
64
65    /// Sequential failover groups (`|`-separated). Within each group,
66    /// endpoints ring simultaneously (`,`-separated).
67    pub fn groups(&self) -> &[Vec<Endpoint>] {
68        &self.groups
69    }
70
71    /// Mutable reference to the default-scope variables.
72    pub fn variables_mut(&mut self) -> &mut Option<Variables> {
73        &mut self.variables
74    }
75
76    /// Mutable reference to the failover groups.
77    pub fn groups_mut(&mut self) -> &mut Vec<Vec<Endpoint>> {
78        &mut self.groups
79    }
80}
81
82/// Renders a [`BridgeDialString`] for one parser revision. Returned by
83/// [`BridgeDialString::display_with`].
84#[derive(Debug, Clone, Copy)]
85pub struct BridgeDialStringDisplay<'a> {
86    bridge: &'a BridgeDialString,
87    block_parse: BlockParse,
88}
89
90impl fmt::Display for BridgeDialStringDisplay<'_> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        self.bridge
93            .write_with(f, self.block_parse)
94    }
95}
96
97impl fmt::Display for BridgeDialString {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        self.write_with(f, BlockParse::default())
100    }
101}
102
103impl FromStr for BridgeDialString {
104    type Err = OriginateError;
105
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        Self::parse_with(s, BlockParse::default())
108    }
109}
110
111impl BridgeDialString {
112    /// Render for a switch running `block_parse`, rather than the default
113    /// revision [`Display`](fmt::Display) uses.
114    pub fn display_with(&self, block_parse: BlockParse) -> BridgeDialStringDisplay<'_> {
115        BridgeDialStringDisplay {
116            bridge: self,
117            block_parse,
118        }
119    }
120
121    fn write_with(&self, f: &mut fmt::Formatter<'_>, block_parse: BlockParse) -> fmt::Result {
122        let target = DialStringTarget::new(CARRIER).with_block_parse(block_parse);
123        if let Some(vars) = &self.variables {
124            if !vars.is_empty() {
125                write!(f, "{}", vars.display_for(target))?;
126            }
127        }
128        for (gi, group) in self
129            .groups
130            .iter()
131            .enumerate()
132        {
133            if gi > 0 {
134                f.write_str("|")?;
135            }
136            for (ei, ep) in group
137                .iter()
138                .enumerate()
139            {
140                if ei > 0 {
141                    f.write_str(",")?;
142                }
143                write!(f, "{}", ep.display_for(target))?;
144            }
145        }
146        Ok(())
147    }
148
149    /// Parse a dial string written for a switch running `block_parse`, mirroring
150    /// [`display_with`](Self::display_with).
151    pub fn parse_with(s: &str, block_parse: BlockParse) -> Result<Self, OriginateError> {
152        let target = DialStringTarget::new(CARRIER).with_block_parse(block_parse);
153        let s = s.trim_matches(' ');
154        if s.is_empty() {
155            return Err(OriginateError::ParseError(
156                "empty bridge dial string".into(),
157            ));
158        }
159
160        let list = pipeline::read(s, target).map_err(read_error)?;
161        let [thread] = &list.threads[..] else {
162            return Err(OriginateError::ParseError(
163                "a bridge dial string has no enterprise threads".into(),
164            ));
165        };
166        if !list
167            .blocks
168            .is_empty()
169            || thread
170                .blocks
171                .len()
172                > 1
173        {
174            return Err(OriginateError::ParseError(
175                "a bridge dial string carries one leading variable block".into(),
176            ));
177        }
178        let variables = installed_variables(
179            thread
180                .blocks
181                .first(),
182        )?;
183
184        let mut groups = Vec::new();
185        for group in &thread.groups {
186            let legs: Vec<&Leg> = group
187                .iter()
188                .filter(|leg| {
189                    !leg.blocks
190                        .is_empty()
191                        || !leg
192                            .endpoint
193                            .is_empty()
194                })
195                .collect();
196            let texts: Option<Vec<&str>> = legs
197                .iter()
198                .map(|leg| {
199                    s.get(
200                        leg.raw
201                            .clone(),
202                    )
203                })
204                .collect();
205            if let Some(texts) = texts {
206                let joined = legs
207                    .iter()
208                    .map(|leg| reading(leg))
209                    .collect();
210                if legs_interfere(texts, Some(joined), target) {
211                    return Err(OriginateError::BracketSpansLegs {
212                        group: groups.len(),
213                    });
214                }
215            }
216            let mut endpoints = Vec::new();
217            for leg in legs {
218                if leg
219                    .blocks
220                    .len()
221                    > 1
222                {
223                    return Err(OriginateError::ParseError(
224                        "an endpoint carries one variable block".into(),
225                    ));
226                }
227                let vars = installed_variables(
228                    leg.blocks
229                        .first(),
230                )?;
231                let mut endpoint = Endpoint::parse_bare(&leg.endpoint)?;
232                if vars.is_some() {
233                    endpoint.set_variables(vars);
234                    if endpoint
235                        .variables()
236                        .is_none()
237                    {
238                        return Err(OriginateError::VariablesNotSupported(endpoint.kind()));
239                    }
240                }
241                endpoints.push(endpoint);
242            }
243            if !endpoints.is_empty() {
244                groups.push(endpoints);
245            }
246        }
247
248        let bridge = Self { variables, groups };
249        bridge.check_legs(block_parse)?;
250        Ok(bridge)
251    }
252
253    /// Refuse a group whose legs the switch's comma scan reads across.
254    fn check_legs(&self, block_parse: BlockParse) -> Result<(), OriginateError> {
255        let target = DialStringTarget::new(CARRIER).with_block_parse(block_parse);
256        match self
257            .groups
258            .iter()
259            .position(|group| endpoints_interfere(group, target))
260        {
261            Some(group) => Err(OriginateError::BracketSpansLegs { group }),
262            None => Ok(()),
263        }
264    }
265}
266
267/// What the switch installs on a leg and the endpoint text it dials.
268type LegReading = (Vec<Block>, String);
269
270fn reading(leg: &Leg) -> LegReading {
271    (
272        leg.blocks
273            .clone(),
274        leg.endpoint
275            .clone(),
276    )
277}
278
279/// Each leg the switch reads of `text`, as one group.
280fn read_group(text: &str, target: DialStringTarget) -> Option<Vec<LegReading>> {
281    let list = pipeline::read(text, target).ok()?;
282    let [thread] = &list.threads[..] else {
283        return None;
284    };
285    let [group] = &thread.groups[..] else {
286        return None;
287    };
288    Some(
289        group
290            .iter()
291            .map(reading)
292            .collect(),
293    )
294}
295
296/// Whether the switch reads a group otherwise than each of its leg `texts` dialled alone, as a
297/// bracket range `switch_ivr_originate`'s comma scan runs from one leg into another makes it.
298fn legs_interfere<'a>(
299    texts: impl IntoIterator<Item = &'a str>,
300    joined: Option<Vec<LegReading>>,
301    target: DialStringTarget,
302) -> bool {
303    let Some(alone) = texts
304        .into_iter()
305        .map(|text| read_group(text, target))
306        .collect::<Option<Vec<_>>>()
307    else {
308        return false;
309    };
310    joined != Some(alone.concat())
311}
312
313/// [`legs_interfere`] for a group's endpoints as they render.
314fn endpoints_interfere(group: &[Endpoint], target: DialStringTarget) -> bool {
315    let rendered: Vec<String> = group
316        .iter()
317        .map(|endpoint| {
318            endpoint
319                .display_for(target)
320                .to_string()
321        })
322        .collect();
323    let joined = read_group(&rendered.join(","), target);
324    legs_interfere(
325        rendered
326            .iter()
327            .map(String::as_str),
328        joined,
329        target,
330    )
331}
332
333#[cfg(feature = "serde")]
334mod config {
335    use crate::commands::endpoint::Endpoint;
336    use crate::commands::variables::Variables;
337
338    #[derive(serde::Deserialize)]
339    pub(super) struct BridgeDialString {
340        #[serde(default)]
341        pub(super) variables: Option<Variables>,
342        pub(super) groups: Vec<Vec<Endpoint>>,
343    }
344}
345
346#[cfg(feature = "serde")]
347impl TryFrom<config::BridgeDialString> for BridgeDialString {
348    type Error = OriginateError;
349
350    fn try_from(config: config::BridgeDialString) -> Result<Self, Self::Error> {
351        let bridge = Self {
352            variables: config.variables,
353            groups: config.groups,
354        };
355        bridge.check_legs(BlockParse::default())?;
356        Ok(bridge)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::commands::endpoint::{
364        AudioEndpoint, DialString, ErrorEndpoint, LoopbackEndpoint, SofiaEndpoint, SofiaGateway,
365    };
366    use crate::commands::variables::{BlockParse, VariablesType};
367
368    /// The comma scan runs after the application's argument is expanded, which here unescapes the
369    /// quote that kept the `[` and `]` apart, so the switch dials both endpoints as one leg.
370    #[cfg(feature = "serde")]
371    #[test]
372    fn a_bracket_spanning_legs_after_expansion_is_refused() {
373        let bridge = BridgeDialString::new(vec![vec![
374            crate::commands::endpoint::UserEndpoint::new("['").into(),
375            SofiaGateway::new("", "]")
376                .with_profile(" ")
377                .into(),
378        ]]);
379        let json = serde_json::to_value(&bridge).unwrap();
380        assert!(matches!(
381            serde_json::from_value::<BridgeDialString>(json)
382                .map_err(|e| e.to_string()),
383            Err(e) if e.contains("group 0")
384        ));
385    }
386
387    // === Display ===
388
389    /// `Display` and `FromStr` are the default revision, not a second render path.
390    #[test]
391    fn display_and_from_str_are_the_default_revision() {
392        let mut vars = Variables::new(VariablesType::Default);
393        vars.insert("cid", "it's");
394        let mut ep_vars = Variables::new(VariablesType::Channel);
395        ep_vars.insert("path", r"C:\path");
396        let bridge = BridgeDialString::new(vec![vec![SofiaGateway::new("gw", "1234")
397            .with_variables(ep_vars)
398            .into()]])
399        .with_variables(vars);
400
401        let rendered = bridge
402            .display_with(BlockParse::PairSplitCleans)
403            .to_string();
404        assert_eq!(rendered, bridge.to_string());
405        assert_eq!(
406            BridgeDialString::parse_with(&rendered, BlockParse::PairSplitCleans)
407                .unwrap_or_else(|e| panic!("{rendered} failed to parse: {e}")),
408            bridge
409        );
410    }
411
412    /// The block reaches a dialplan application whole, one tokenizer pass
413    /// shallower than an `originate` argument, so a quoted value carries one
414    /// backslash fewer here than the same value rendered on its own.
415    #[test]
416    fn bridge_renders_one_level_shallower_than_the_default() {
417        let mut vars = Variables::new(VariablesType::Default);
418        vars.insert("cid", "it's");
419        let ep = SofiaGateway::new("gw", "18005551234");
420        let bridge = BridgeDialString::new(vec![vec![ep.into()]]).with_variables(vars.clone());
421
422        assert_eq!(
423            bridge.to_string(),
424            r"{cid=it\\\\\\'s}sofia/gateway/gw/18005551234"
425        );
426        assert_eq!(vars.to_string(), r"{cid=it\\\\\\\'s}");
427    }
428
429    /// Both halves have to agree on the carrier. Rendering at dialplan depth
430    /// and parsing back at the default would unescape one level too deep and
431    /// silently return a different value than was put in.
432    #[test]
433    fn per_endpoint_escaped_values_round_trip() {
434        let mut ep_vars = Variables::new(VariablesType::Channel);
435        ep_vars.insert("path", r"C:\path");
436        ep_vars.insert("other", "a,b");
437        let bridge = BridgeDialString::new(vec![vec![SofiaGateway::new("gw", "1234")
438            .with_variables(ep_vars)
439            .into()]]);
440
441        let rendered = bridge.to_string();
442        let back: BridgeDialString = rendered
443            .parse()
444            .unwrap_or_else(|e| panic!("{rendered} failed to parse: {e}"));
445        assert_eq!(back, bridge, "rendered {rendered}");
446    }
447
448    /// A quote in a `[]` block pairs across values before the switch parses the
449    /// block, at any escaping depth, so the parser refuses one rather than hand
450    /// back a value the wire would not deliver.
451    #[test]
452    fn per_endpoint_quoted_value_is_refused() {
453        let err = r"[cid=it\\\\\\'s]sofia/gateway/gw/1234"
454            .parse::<BridgeDialString>()
455            .unwrap_err()
456            .to_string();
457        assert!(
458            err.contains("cid") && err.contains("channel scope"),
459            "error does not name the variable and scope: {err}"
460        );
461    }
462
463    #[test]
464    fn display_single_endpoint() {
465        let bridge =
466            BridgeDialString::new(vec![vec![
467                SofiaGateway::new("my_provider", "18005551234").into()
468            ]]);
469        assert_eq!(bridge.to_string(), "sofia/gateway/my_provider/18005551234");
470    }
471
472    #[test]
473    fn display_simultaneous_ring() {
474        let bridge = BridgeDialString::new(vec![vec![
475            SofiaGateway::new("primary", "18005551234").into(),
476            SofiaGateway::new("secondary", "18005551234").into(),
477        ]]);
478        assert_eq!(
479            bridge.to_string(),
480            "sofia/gateway/primary/18005551234,sofia/gateway/secondary/18005551234"
481        );
482    }
483
484    #[test]
485    fn display_sequential_failover() {
486        let bridge = BridgeDialString::new(vec![
487            vec![SofiaGateway::new("primary", "18005551234").into()],
488            vec![SofiaGateway::new("backup", "18005551234").into()],
489        ]);
490        assert_eq!(
491            bridge.to_string(),
492            "sofia/gateway/primary/18005551234|sofia/gateway/backup/18005551234"
493        );
494    }
495
496    #[test]
497    fn display_mixed_simultaneous_and_sequential() {
498        let bridge = BridgeDialString::new(vec![
499            vec![
500                SofiaGateway::new("primary", "1234").into(),
501                SofiaGateway::new("secondary", "1234").into(),
502            ],
503            vec![SofiaGateway::new("backup", "1234").into()],
504        ]);
505        assert_eq!(
506            bridge.to_string(),
507            "sofia/gateway/primary/1234,sofia/gateway/secondary/1234|sofia/gateway/backup/1234"
508        );
509    }
510
511    #[test]
512    fn display_with_global_variables() {
513        let mut vars = Variables::new(VariablesType::Default);
514        vars.insert("hangup_after_bridge", "true");
515        let bridge =
516            BridgeDialString::new(vec![vec![
517                SofiaEndpoint::new("internal", "1000@domain").into()
518            ]])
519            .with_variables(vars);
520        assert_eq!(
521            bridge.to_string(),
522            "{hangup_after_bridge=true}sofia/internal/1000@domain"
523        );
524    }
525
526    #[test]
527    fn display_with_per_endpoint_variables() {
528        let mut ep_vars = Variables::new(VariablesType::Channel);
529        ep_vars.insert("leg_timeout", "30");
530        let bridge = BridgeDialString::new(vec![vec![
531            SofiaGateway::new("gw1", "1234")
532                .with_variables(ep_vars)
533                .into(),
534            SofiaGateway::new("gw2", "1234").into(),
535        ]]);
536        assert_eq!(
537            bridge.to_string(),
538            "[leg_timeout=30]sofia/gateway/gw1/1234,sofia/gateway/gw2/1234"
539        );
540    }
541
542    #[test]
543    fn display_with_error_endpoint_failover() {
544        let bridge = BridgeDialString::new(vec![
545            vec![SofiaGateway::new("primary", "1234").into()],
546            vec![ErrorEndpoint::new(crate::channel::HangupCause::UserBusy).into()],
547        ]);
548        assert_eq!(
549            bridge.to_string(),
550            "sofia/gateway/primary/1234|error/USER_BUSY"
551        );
552    }
553
554    #[test]
555    fn display_with_loopback() {
556        let bridge = BridgeDialString::new(vec![vec![LoopbackEndpoint::new("9199")
557            .with_context("default")
558            .into()]]);
559        assert_eq!(bridge.to_string(), "loopback/9199/default");
560    }
561
562    // === FromStr ===
563
564    #[test]
565    fn from_str_single_endpoint() {
566        let bridge: BridgeDialString = "sofia/gateway/my_provider/18005551234"
567            .parse()
568            .unwrap();
569        assert_eq!(
570            bridge
571                .groups()
572                .len(),
573            1
574        );
575        assert_eq!(bridge.groups()[0].len(), 1);
576        assert!(bridge
577            .variables()
578            .is_none());
579    }
580
581    #[test]
582    fn from_str_simultaneous_ring() {
583        let bridge: BridgeDialString = "sofia/gateway/primary/1234,sofia/gateway/secondary/1234"
584            .parse()
585            .unwrap();
586        assert_eq!(
587            bridge
588                .groups()
589                .len(),
590            1
591        );
592        assert_eq!(bridge.groups()[0].len(), 2);
593    }
594
595    #[test]
596    fn from_str_sequential_failover() {
597        let bridge: BridgeDialString = "sofia/gateway/primary/1234|sofia/gateway/backup/1234"
598            .parse()
599            .unwrap();
600        assert_eq!(
601            bridge
602                .groups()
603                .len(),
604            2
605        );
606        assert_eq!(bridge.groups()[0].len(), 1);
607        assert_eq!(bridge.groups()[1].len(), 1);
608    }
609
610    #[test]
611    fn from_str_mixed() {
612        let bridge: BridgeDialString =
613            "sofia/gateway/primary/1234,sofia/gateway/secondary/1234|sofia/gateway/backup/1234"
614                .parse()
615                .unwrap();
616        assert_eq!(
617            bridge
618                .groups()
619                .len(),
620            2
621        );
622        assert_eq!(bridge.groups()[0].len(), 2);
623        assert_eq!(bridge.groups()[1].len(), 1);
624    }
625
626    #[test]
627    fn from_str_with_global_variables() {
628        let bridge: BridgeDialString = "{hangup_after_bridge=true}sofia/internal/1000@domain"
629            .parse()
630            .unwrap();
631        assert!(bridge
632            .variables()
633            .is_some());
634        assert_eq!(
635            bridge
636                .variables()
637                .unwrap()
638                .get("hangup_after_bridge"),
639            Some("true")
640        );
641        assert_eq!(
642            bridge
643                .groups()
644                .len(),
645            1
646        );
647        assert_eq!(bridge.groups()[0].len(), 1);
648    }
649
650    #[test]
651    fn from_str_with_per_endpoint_variables() {
652        let bridge: BridgeDialString =
653            "[leg_timeout=30]sofia/gateway/gw1/1234,sofia/gateway/gw2/1234"
654                .parse()
655                .unwrap();
656        assert_eq!(
657            bridge
658                .groups()
659                .len(),
660            1
661        );
662        assert_eq!(bridge.groups()[0].len(), 2);
663        let ep = &bridge.groups()[0][0];
664        if let Endpoint::SofiaGateway(gw) = ep {
665            assert!(gw
666                .variables
667                .is_some());
668        } else {
669            panic!("expected SofiaGateway");
670        }
671    }
672
673    /// An enterprise block is global too. Read as part of the first endpoint it
674    /// either fails the parse or lands on one leg of a forked dial.
675    #[test]
676    fn from_str_with_enterprise_global_variables() {
677        let bridge: BridgeDialString = "<originate_timeout=60>sofia/internal/1000@domain"
678            .parse()
679            .unwrap();
680        assert_eq!(
681            bridge
682                .variables()
683                .expect("enterprise block is global")
684                .get("originate_timeout"),
685            Some("60")
686        );
687        assert_eq!(bridge.groups()[0].len(), 1);
688    }
689
690    /// A channel block binds to the endpoint that follows it, so the global
691    /// slot must not take it.
692    #[test]
693    fn from_str_leaves_a_channel_block_on_its_endpoint() {
694        let bridge: BridgeDialString = "[leg_timeout=30]sofia/gateway/gw1/1234"
695            .parse()
696            .unwrap();
697        assert!(bridge
698            .variables()
699            .is_none());
700        assert!(bridge.groups()[0][0]
701            .variables()
702            .is_some());
703    }
704
705    #[test]
706    fn from_str_round_trip_single() {
707        let input = "sofia/gateway/my_provider/18005551234";
708        let bridge: BridgeDialString = input
709            .parse()
710            .unwrap();
711        assert_eq!(bridge.to_string(), input);
712    }
713
714    #[test]
715    fn from_str_round_trip_mixed() {
716        let input =
717            "sofia/gateway/primary/1234,sofia/gateway/secondary/1234|sofia/gateway/backup/1234";
718        let bridge: BridgeDialString = input
719            .parse()
720            .unwrap();
721        assert_eq!(bridge.to_string(), input);
722    }
723
724    #[test]
725    fn from_str_round_trip_with_global_vars() {
726        let input = "{hangup_after_bridge=true}sofia/internal/1000@domain";
727        let bridge: BridgeDialString = input
728            .parse()
729            .unwrap();
730        assert_eq!(bridge.to_string(), input);
731    }
732
733    // === Serde ===
734
735    #[test]
736    fn serde_round_trip_single() {
737        let bridge =
738            BridgeDialString::new(vec![vec![
739                SofiaGateway::new("my_provider", "18005551234").into()
740            ]]);
741        let json = serde_json::to_string(&bridge).unwrap();
742        let parsed: BridgeDialString = serde_json::from_str(&json).unwrap();
743        assert_eq!(bridge, parsed);
744    }
745
746    #[test]
747    fn serde_round_trip_multi_group() {
748        let mut vars = Variables::new(VariablesType::Default);
749        vars.insert("hangup_after_bridge", "true");
750        let bridge = BridgeDialString::new(vec![
751            vec![
752                SofiaGateway::new("primary", "1234").into(),
753                SofiaGateway::new("secondary", "1234").into(),
754            ],
755            vec![ErrorEndpoint::new(crate::channel::HangupCause::UserBusy).into()],
756        ])
757        .with_variables(vars);
758        let json = serde_json::to_string(&bridge).unwrap();
759        let parsed: BridgeDialString = serde_json::from_str(&json).unwrap();
760        assert_eq!(bridge, parsed);
761    }
762
763    // === Edge cases ===
764
765    #[test]
766    fn from_str_empty_string_rejected() {
767        let result = "".parse::<BridgeDialString>();
768        assert!(result.is_err());
769    }
770
771    #[test]
772    fn from_str_whitespace_only_rejected() {
773        let result = "   ".parse::<BridgeDialString>();
774        assert!(result.is_err());
775    }
776
777    #[test]
778    fn from_str_empty_groups_from_trailing_pipe() {
779        // "ep1|" should parse as one group (empty trailing group is skipped)
780        let bridge: BridgeDialString = "sofia/gateway/gw1/1234|"
781            .parse()
782            .unwrap();
783        assert_eq!(
784            bridge
785                .groups()
786                .len(),
787            1
788        );
789    }
790
791    #[test]
792    fn from_str_empty_variable_block() {
793        let bridge: BridgeDialString = "{}sofia/gateway/gw1/1234"
794            .parse()
795            .unwrap();
796        assert!(bridge
797            .variables()
798            .is_none());
799        assert_eq!(
800            bridge
801                .groups()
802                .len(),
803            1
804        );
805    }
806
807    #[test]
808    fn from_str_mismatched_bracket_rejected() {
809        let result = "{unclosed=true sofia/gateway/gw1/1234".parse::<BridgeDialString>();
810        assert!(result.is_err());
811    }
812
813    /// A separator inside endpoint text is escaped for the leg split that would read it.
814    #[test]
815    fn a_separator_in_endpoint_text_round_trips() {
816        let bridge = BridgeDialString::new(vec![
817            vec![
818                SofiaEndpoint::new("internal", "a,b").into(),
819                SofiaEndpoint::new("internal", "c|d").into(),
820            ],
821            vec![LoopbackEndpoint::new("it's a").into()],
822        ]);
823        let rendered = bridge.to_string();
824        let back: BridgeDialString = rendered
825            .parse()
826            .unwrap_or_else(|e| panic!("{rendered} failed to parse: {e}"));
827        assert_eq!(back, bridge, "rendered {rendered}");
828    }
829
830    /// The switch's comma scan protects commas from a `[` to its matching `]`, whichever leg
831    /// holds either, so a range crossing a leg separator merges the legs.
832    #[test]
833    fn a_bracket_spanning_legs_is_refused_at_config_load() {
834        let spanning = r#"{"groups":[[{"sofia":{"profile":"internal","destination":"x["}},{"sofia":{"profile":"internal","destination":"y]"}}]]}"#;
835        assert!(serde_json::from_str::<BridgeDialString>(spanning).is_err());
836        for fine in [
837            r#"{"groups":[[{"sofia":{"profile":"internal","destination":"x[]"}},{"sofia":{"profile":"internal","destination":"y]"}}]]}"#,
838            r#"{"groups":[[{"sofia":{"profile":"internal","destination":"x["}}],[{"sofia":{"profile":"internal","destination":"y]"}}]]}"#,
839        ] {
840            assert!(
841                serde_json::from_str::<BridgeDialString>(fine).is_ok(),
842                "{fine}"
843            );
844        }
845    }
846
847    /// A `[` in an earlier leg, its `]` in a later leg's endpoint, and a `^^` block between.
848    fn bracket_around_block(sep: char, value: &str) -> BridgeDialString {
849        let mut vars = Variables::new(VariablesType::Channel)
850            .with_separator(sep)
851            .unwrap();
852        vars.insert("v0", value);
853        BridgeDialString::new(vec![vec![
854            Endpoint::PortAudio(AudioEndpoint::new().with_destination("[\\")),
855            SofiaGateway::new("]", "")
856                .with_variables(vars)
857                .into(),
858        ]])
859    }
860
861    /// The comma scan takes the `^^,` head inside the range for a plain comma and writes the
862    /// block's own default, which the block then splits on alike.
863    #[test]
864    fn a_comma_head_the_scan_rewrites_round_trips() {
865        let bridge = bracket_around_block(',', "undef");
866        let rendered = bridge.to_string();
867        let back: BridgeDialString = rendered
868            .parse()
869            .unwrap_or_else(|e| panic!("{rendered} failed to parse: {e}"));
870        assert_eq!(
871            back.to_string(),
872            r"portaudio/[\\\\\\\\,[v0=undef]sofia/gateway/]/"
873        );
874        let json = serde_json::to_value(&bridge).unwrap();
875        assert_eq!(
876            serde_json::from_value::<BridgeDialString>(json).unwrap(),
877            bridge
878        );
879    }
880
881    /// The range opened in the first leg decides how the scan rewrites a comma in the second
882    /// leg's `^^:` value, which then reaches the channel as the scan's placeholder.
883    #[test]
884    fn a_range_rewriting_a_later_legs_value_is_refused() {
885        let bridge = bracket_around_block(':', "a,b");
886        let rendered = bridge.to_string();
887        assert!(
888            matches!(
889                rendered.parse::<BridgeDialString>(),
890                Err(OriginateError::BracketSpansLegs { group: 0 })
891            ),
892            "{rendered}: {:?}",
893            rendered.parse::<BridgeDialString>()
894        );
895        let json = serde_json::to_value(&bridge).unwrap();
896        assert!(serde_json::from_value::<BridgeDialString>(json).is_err());
897    }
898
899    /// The dialplan carrier's expansion, the leg splits over a `[]` block and both of a block's own
900    /// splits each read `\\` as one backslash, and none reads `\b` or `\d`.
901    #[test]
902    fn parse_reads_blocks_as_the_switch_installs_them() {
903        let bridge: BridgeDialString = r"{k=a\\\\b}[j=c\\\\\\\\d]loopback/9199/test"
904            .parse()
905            .unwrap_or_else(|e| panic!("{e}"));
906        assert_eq!(
907            bridge
908                .variables()
909                .and_then(|vars| vars.get("k")),
910            Some(r"a\b")
911        );
912        assert_eq!(
913            bridge.groups()[0][0]
914                .variables()
915                .and_then(|vars| vars.get("j")),
916            Some(r"c\d")
917        );
918    }
919
920    #[test]
921    fn serde_to_display_wire_format() {
922        let json = r#"{
923            "groups": [[{
924                "sofia_gateway": {
925                    "gateway": "my_gw",
926                    "destination": "18005551234"
927                }
928            }]]
929        }"#;
930        let bridge: BridgeDialString = serde_json::from_str(json).unwrap();
931        assert_eq!(bridge.to_string(), "sofia/gateway/my_gw/18005551234");
932    }
933}