Skip to main content

freeswitch_types/commands/
mod.rs

1//! Command string builders for [`api()`] and [`bgapi()`].
2//!
3//! [`api()`]: https://docs.rs/freeswitch-esl-tokio/latest/freeswitch_esl_tokio/connection/struct.EslClient.html#method.api
4//! [`bgapi()`]: https://docs.rs/freeswitch-esl-tokio/latest/freeswitch_esl_tokio/connection/struct.EslClient.html#method.bgapi
5//!
6//! Each builder implements [`Display`](std::fmt::Display), producing the argument
7//! string for the corresponding FreeSWITCH API command.  The builders perform
8//! escaping and validation so callers don't need to worry about wire-format
9//! details.
10
11pub mod bridge;
12pub mod channel;
13pub mod conference;
14pub mod endpoint;
15pub mod originate;
16
17pub use bridge::BridgeDialString;
18pub use channel::{
19    UuidAnswer, UuidBridge, UuidDeflect, UuidGetVar, UuidHold, UuidKill, UuidSendDtmf, UuidSetVar,
20    UuidTransfer,
21};
22pub use conference::{ConferenceDtmf, ConferenceHold, ConferenceMute, HoldAction, MuteAction};
23pub use endpoint::{
24    AudioEndpoint, DialString, ErrorEndpoint, GroupCall, GroupCallOrder, LoopbackEndpoint,
25    ParseGroupCallOrderError, SofiaContact, SofiaEndpoint, SofiaGateway, UserEndpoint,
26};
27pub use originate::{
28    Application, DialplanType, Endpoint, Originate, OriginateError, OriginateTarget,
29    ParseDialplanTypeError, Variables, VariablesType,
30};
31
32/// Find the index of the closing bracket matching the opener at position 0.
33///
34/// Tracks nesting depth so that inner pairs of the same bracket type are
35/// skipped. Returns `None` if the string never reaches depth 0.
36pub(crate) fn find_matching_bracket(s: &str, open: char, close: char) -> Option<usize> {
37    let mut depth = 0;
38    for (i, ch) in s.char_indices() {
39        if ch == open {
40            depth += 1;
41        } else if ch == close {
42            depth -= 1;
43            if depth == 0 {
44                return Some(i);
45            }
46        }
47    }
48    None
49}
50
51/// Wrap a token in single quotes for originate command strings.
52///
53/// If `token` contains spaces, it is wrapped in `'...'` with any inner
54/// single quotes escaped as `\'`.  Tokens without spaces are returned as-is.
55pub fn originate_quote(token: &str) -> String {
56    if token.contains(' ') {
57        let escaped = token.replace('\'', "\\'");
58        format!("'{}'", escaped)
59    } else {
60        token.to_string()
61    }
62}
63
64/// Strip single-quote wrapping added by [`originate_quote`].
65///
66/// If the token starts and ends with `'`, the outer quotes are removed
67/// and `\'` sequences are unescaped back to `'`.
68pub fn originate_unquote(token: &str) -> String {
69    match token
70        .strip_prefix('\'')
71        .and_then(|s| s.strip_suffix('\''))
72    {
73        Some(inner) => inner.replace("\\'", "'"),
74        None => token.to_string(),
75    }
76}
77
78/// Quote-aware tokenizer for originate command strings.
79///
80/// Splits `line` on `split_at` (default: space), respecting single-quote
81/// pairing to avoid splitting inside quoted values. Backslash-escaped quotes
82/// are not treated as quote boundaries.
83///
84/// Ported from Python `originate_split()`.
85pub fn originate_split(line: &str, split_at: char) -> Result<Vec<String>, OriginateError> {
86    let mut tokens = Vec::new();
87    let mut token = String::new();
88    let mut in_quote = false;
89    let chars: Vec<char> = line
90        .chars()
91        .collect();
92    let mut i = 0;
93
94    while i < chars.len() {
95        let ch = chars[i];
96
97        if ch == split_at
98            && !in_quote
99            && !token
100                .trim()
101                .is_empty()
102        {
103            tokens.push(
104                token
105                    .trim()
106                    .to_string(),
107            );
108            token.clear();
109            i += 1;
110            continue;
111        }
112
113        if ch == '\'' && !(i > 0 && chars[i - 1] == '\\') {
114            in_quote = !in_quote;
115        }
116
117        token.push(ch);
118        i += 1;
119    }
120
121    if in_quote {
122        return Err(OriginateError::UnclosedQuote(token));
123    }
124
125    let token = token
126        .trim()
127        .to_string();
128    if !token.is_empty() {
129        tokens.push(token);
130    }
131
132    Ok(tokens)
133}
134
135/// Parse the target argument of an originate command.
136///
137/// Determines whether the target is a dialplan extension or application(s):
138/// - If dialplan is `Inline`: parse as inline apps → `InlineApplications`
139/// - If string starts with `&`: parse as XML app → `Application`
140/// - Otherwise: bare string → `Extension`
141pub fn parse_originate_target(
142    s: &str,
143    dialplan: Option<&DialplanType>,
144) -> Result<OriginateTarget, OriginateError> {
145    if matches!(dialplan, Some(DialplanType::Inline)) {
146        let mut apps = Vec::new();
147        for part in originate_split(s, ',')? {
148            let (name, args) = match part.split_once(':') {
149                Some((n, "")) => (n, None),
150                Some((n, a)) => (n, Some(a)),
151                None => (part.as_str(), None),
152            };
153            apps.push(Application::new(name, args));
154        }
155        Ok(OriginateTarget::InlineApplications(apps))
156    } else if let Some(rest) = s.strip_prefix('&') {
157        let rest = rest
158            .strip_suffix(')')
159            .ok_or_else(|| OriginateError::ParseError("missing closing paren".into()))?;
160        let (name, args) = rest
161            .split_once('(')
162            .ok_or_else(|| OriginateError::ParseError("missing opening paren".into()))?;
163        let args = if args.is_empty() { None } else { Some(args) };
164        Ok(OriginateTarget::Application(Application::new(name, args)))
165    } else {
166        Ok(OriginateTarget::Extension(s.to_string()))
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn find_matching_bracket_simple() {
176        assert_eq!(find_matching_bracket("{abc}", '{', '}'), Some(4));
177    }
178
179    #[test]
180    fn find_matching_bracket_nested() {
181        assert_eq!(find_matching_bracket("{a={b}}", '{', '}'), Some(6));
182    }
183
184    #[test]
185    fn find_matching_bracket_unclosed() {
186        assert_eq!(find_matching_bracket("{a={b}", '{', '}'), None);
187    }
188
189    #[test]
190    fn find_matching_bracket_angle() {
191        assert_eq!(find_matching_bracket("<a=<b>>rest", '<', '>'), Some(6));
192    }
193
194    #[test]
195    fn split_with_quotes_ignores_spaces_inside() {
196        let result =
197            originate_split("originate {test='variable with quote'}sofia/test 123", ' ').unwrap();
198        assert_eq!(result[0], "originate");
199        assert_eq!(result[1], "{test='variable with quote'}sofia/test");
200        assert_eq!(result[2], "123");
201    }
202
203    #[test]
204    fn split_missing_quote_returns_error() {
205        let result = originate_split(
206            "originate {test='variable with missing quote}sofia/test 123",
207            ' ',
208        );
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn split_string_starting_ending_with_quote() {
214        let result = originate_split("'this is test'", ' ').unwrap();
215        assert_eq!(result[0], "'this is test'");
216    }
217
218    #[test]
219    fn split_comma_separated() {
220        let result = originate_split("item1,item2", ',').unwrap();
221        assert_eq!(result[0], "item1");
222        assert_eq!(result[1], "item2");
223    }
224
225    #[test]
226    fn split_with_escaped_quotes() {
227        let result = originate_split(
228            "originate {test='variable with quote'}sofia/test let\\'s add a quote",
229            ' ',
230        )
231        .unwrap();
232        assert_eq!(result[0], "originate");
233        assert_eq!(result[1], "{test='variable with quote'}sofia/test");
234        assert_eq!(result[2], "let\\'s");
235        assert_eq!(result[3], "add");
236        assert_eq!(result[4], "a");
237        assert_eq!(result[5], "quote");
238    }
239
240    #[test]
241    fn quote_without_spaces_returns_as_is() {
242        assert_eq!(originate_quote("&park()"), "&park()");
243    }
244
245    #[test]
246    fn quote_with_spaces_wraps_in_single_quotes() {
247        assert_eq!(
248            originate_quote("&socket(127.0.0.1:8040 async full)"),
249            "'&socket(127.0.0.1:8040 async full)'"
250        );
251    }
252
253    #[test]
254    fn quote_with_single_quote_and_spaces_escapes_quote() {
255        assert_eq!(
256            originate_quote("&playback(it's a test file)"),
257            "'&playback(it\\'s a test file)'"
258        );
259    }
260
261    #[test]
262    fn unquote_non_quoted_returns_as_is() {
263        assert_eq!(originate_unquote("&park()"), "&park()");
264    }
265
266    #[test]
267    fn unquote_strips_outer_quotes() {
268        assert_eq!(
269            originate_unquote("'&socket(127.0.0.1:8040 async full)'"),
270            "&socket(127.0.0.1:8040 async full)"
271        );
272    }
273
274    #[test]
275    fn unquote_unescapes_inner_quotes() {
276        assert_eq!(
277            originate_unquote("'&playback(it\\'s a test file)'"),
278            "&playback(it's a test file)"
279        );
280    }
281
282    #[test]
283    fn quote_unquote_round_trip() {
284        let original = "&socket(127.0.0.1:8040 async full)";
285        assert_eq!(originate_unquote(&originate_quote(original)), original);
286    }
287
288    #[test]
289    fn quote_unquote_round_trip_with_inner_quote() {
290        let original = "&playback(it's a test file)";
291        assert_eq!(originate_unquote(&originate_quote(original)), original);
292    }
293
294    // --- T5: originate_split with multiple consecutive spaces ---
295
296    #[test]
297    fn split_multiple_consecutive_spaces() {
298        let result = originate_split("originate  sofia/test  123", ' ').unwrap();
299        // Multiple consecutive spaces produce empty tokens that are trimmed/skipped
300        assert_eq!(result[0], "originate");
301        assert_eq!(result[1], "sofia/test");
302        assert_eq!(result[2], "123");
303    }
304
305    #[test]
306    fn split_leading_trailing_spaces() {
307        let result = originate_split("  originate sofia/test  ", ' ').unwrap();
308        assert_eq!(result[0], "originate");
309        assert_eq!(result[1], "sofia/test");
310    }
311
312    #[test]
313    fn parse_target_bare_extension() {
314        let target = parse_originate_target("123", None).unwrap();
315        assert!(matches!(target, OriginateTarget::Extension(ref e) if e == "123"));
316    }
317
318    #[test]
319    fn parse_target_xml_no_args() {
320        let target = parse_originate_target("&conference()", None).unwrap();
321        if let OriginateTarget::Application(app) = target {
322            assert_eq!(app.name(), "conference");
323            assert!(app
324                .args()
325                .is_none());
326        } else {
327            panic!("expected Application");
328        }
329    }
330
331    #[test]
332    fn parse_target_xml_with_args() {
333        let target = parse_originate_target("&conference(1)", None).unwrap();
334        if let OriginateTarget::Application(app) = target {
335            assert_eq!(app.name(), "conference");
336            assert_eq!(app.args(), Some("1"));
337        } else {
338            panic!("expected Application");
339        }
340    }
341
342    #[test]
343    fn parse_target_two_inline_apps() {
344        let target = parse_originate_target(
345            "conference:1,hangup:NORMAL_CLEARING",
346            Some(&DialplanType::Inline),
347        )
348        .unwrap();
349        if let OriginateTarget::InlineApplications(apps) = target {
350            assert_eq!(apps.len(), 2);
351            assert_eq!(apps[0].name(), "conference");
352            assert_eq!(apps[0].args(), Some("1"));
353            assert_eq!(apps[1].name(), "hangup");
354            assert_eq!(apps[1].args(), Some("NORMAL_CLEARING"));
355        } else {
356            panic!("expected InlineApplications");
357        }
358    }
359
360    #[test]
361    fn parse_target_inline_bare_name() {
362        let target = parse_originate_target("hangup", Some(&DialplanType::Inline)).unwrap();
363        if let OriginateTarget::InlineApplications(apps) = target {
364            assert_eq!(apps.len(), 1);
365            assert_eq!(apps[0].name(), "hangup");
366            assert!(apps[0]
367                .args()
368                .is_none());
369        } else {
370            panic!("expected InlineApplications");
371        }
372    }
373
374    #[test]
375    fn parse_target_inline_mixed_bare_and_args() {
376        let target =
377            parse_originate_target("park,hangup:NORMAL_CLEARING", Some(&DialplanType::Inline))
378                .unwrap();
379        if let OriginateTarget::InlineApplications(apps) = target {
380            assert_eq!(apps.len(), 2);
381            assert_eq!(apps[0].name(), "park");
382            assert!(apps[0]
383                .args()
384                .is_none());
385            assert_eq!(apps[1].name(), "hangup");
386            assert_eq!(apps[1].args(), Some("NORMAL_CLEARING"));
387        } else {
388            panic!("expected InlineApplications");
389        }
390    }
391
392    #[test]
393    fn parse_target_inline_trailing_colon_collapses_to_none() {
394        let target = parse_originate_target("park:", Some(&DialplanType::Inline)).unwrap();
395        if let OriginateTarget::InlineApplications(apps) = target {
396            assert!(apps[0]
397                .args()
398                .is_none());
399        } else {
400            panic!("expected InlineApplications");
401        }
402    }
403}