freeswitch_types/commands/
mod.rs1pub 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
33pub(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
52pub 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
65pub 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
79pub 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
136pub 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 #[test]
298 fn split_multiple_consecutive_spaces() {
299 let result = originate_split("originate sofia/test 123", ' ').unwrap();
300 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}