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