freeswitch_types/commands/
mod.rs1pub 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
32pub(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
51pub 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
64pub 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
78pub 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
135pub 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 #[test]
297 fn split_multiple_consecutive_spaces() {
298 let result = originate_split("originate sofia/test 123", ' ').unwrap();
299 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}