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