Skip to main content

freeswitch_types/commands/
originate.rs

1//! Originate command builder with endpoint configuration, variable scoping,
2//! and automatic quoting for socket application arguments.
3
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8use super::{originate_quote, originate_split, originate_unquote};
9
10pub use super::variables::{Variables, VariablesType};
11
12/// FreeSWITCH keyword for omitted positional arguments.
13///
14/// `switch_separate_string` converts `"undef"` to NULL, making it the
15/// canonical placeholder when a later positional arg forces earlier ones
16/// to be present on the wire.
17const UNDEF: &str = "undef";
18
19/// FreeSWITCH dialplan type for originate commands.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
23#[non_exhaustive]
24pub enum DialplanType {
25    /// Inline dialplan: applications execute directly without XML lookup.
26    Inline,
27    /// XML dialplan: route through the XML dialplan engine.
28    Xml,
29}
30
31impl fmt::Display for DialplanType {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Inline => f.write_str("inline"),
35            Self::Xml => f.write_str("XML"),
36        }
37    }
38}
39
40parse_error! { ParseDialplanTypeError("dialplan type"); }
41
42impl FromStr for DialplanType {
43    type Err = ParseDialplanTypeError;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        if s.eq_ignore_ascii_case("inline") {
47            Ok(Self::Inline)
48        } else if s.eq_ignore_ascii_case("xml") {
49            Ok(Self::Xml)
50        } else {
51            Err(ParseDialplanTypeError(s.to_string()))
52        }
53    }
54}
55
56// Endpoint is now defined in endpoint.rs -- re-exported via pub use below.
57pub use super::endpoint::Endpoint;
58
59/// A single dialplan application with optional arguments.
60///
61/// Formats differently depending on [`DialplanType`]:
62/// - Inline: `name` or `name:args`
63/// - XML: `&name(args)`
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct Application {
67    name: String,
68    #[cfg_attr(
69        feature = "serde",
70        serde(default, skip_serializing_if = "Option::is_none")
71    )]
72    args: Option<String>,
73}
74
75impl Application {
76    /// Create an application with optional arguments.
77    pub fn new(name: impl Into<String>, args: Option<impl Into<String>>) -> Self {
78        Self {
79            name: name.into(),
80            args: args.map(|a| a.into()),
81        }
82    }
83
84    /// Create an application with no arguments.
85    pub fn simple(name: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            args: None,
89        }
90    }
91
92    /// Park the channel (hold in place without bridging).
93    pub fn park() -> Self {
94        Self::simple("park")
95    }
96
97    /// Application name (e.g. `park`, `conference`, `socket`).
98    pub fn name(&self) -> &str {
99        &self.name
100    }
101
102    /// Application arguments, if any.
103    pub fn args(&self) -> Option<&str> {
104        self.args
105            .as_deref()
106    }
107
108    /// Mutable reference to the application name.
109    pub fn name_mut(&mut self) -> &mut String {
110        &mut self.name
111    }
112
113    /// Mutable reference to the application arguments.
114    pub fn args_mut(&mut self) -> &mut Option<String> {
115        &mut self.args
116    }
117
118    /// Format as inline (`name:args`) or XML (`&name(args)`) syntax.
119    pub fn to_string_with_dialplan(&self, dialplan: &DialplanType) -> String {
120        match dialplan {
121            DialplanType::Inline => match &self.args {
122                Some(args) => format!("{}:{}", self.name, args),
123                None => self
124                    .name
125                    .clone(),
126            },
127            // XML and custom dialplans use the &app(args) syntax.
128            _ => {
129                let args = self
130                    .args
131                    .as_deref()
132                    .unwrap_or("");
133                format!("&{}({})", self.name, args)
134            }
135        }
136    }
137}
138
139/// The target of an originate command: either a dialplan extension or
140/// application(s) to execute directly.
141///
142/// FreeSWITCH syntax: `originate <endpoint> <target> [dialplan] ...`
143/// where `<target>` is either a bare extension string (routes through
144/// the dialplan engine) or `&app(args)` / `app:args` (executes inline).
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
148#[non_exhaustive]
149pub enum OriginateTarget {
150    /// Route through the dialplan engine to this extension.
151    Extension(String),
152    /// Single application for XML dialplan: `&app(args)`.
153    Application(Application),
154    /// One or more applications for inline dialplan: `app:args,app:args`.
155    InlineApplications(Vec<Application>),
156}
157
158impl From<Application> for OriginateTarget {
159    fn from(app: Application) -> Self {
160        Self::Application(app)
161    }
162}
163
164impl From<Vec<Application>> for OriginateTarget {
165    fn from(apps: Vec<Application>) -> Self {
166        Self::InlineApplications(apps)
167    }
168}
169
170/// Originate command builder: `originate <endpoint> <target> [dialplan] [context] [cid_name] [cid_num] [timeout]`.
171///
172/// Constructed via [`Originate::extension`], [`Originate::application`], or
173/// [`Originate::inline`]. Invalid states (Extension + Inline dialplan, empty
174/// inline apps) are rejected at construction time rather than at `Display`.
175///
176/// Optional fields are set via consuming-self chaining methods:
177///
178/// ```
179/// # use std::time::Duration;
180/// # use freeswitch_types::commands::*;
181/// let cmd = Originate::application(
182///     Endpoint::Loopback(LoopbackEndpoint::new("9196").with_context("default")),
183///     Application::simple("park"),
184/// )
185/// .cid_name("Alice")
186/// .cid_num("5551234")
187/// .timeout(Duration::from_secs(30));
188/// ```
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct Originate {
191    endpoint: Endpoint,
192    target: OriginateTarget,
193    dialplan: Option<DialplanType>,
194    context: Option<String>,
195    cid_name: Option<String>,
196    cid_num: Option<String>,
197    timeout: Option<Duration>,
198}
199
200#[cfg(feature = "serde")]
201mod serde_support {
202    use super::*;
203
204    /// Intermediate type for serde, mirroring the old public-field layout.
205    #[derive(serde::Serialize, serde::Deserialize)]
206    pub(super) struct OriginateRaw {
207        pub endpoint: Endpoint,
208        #[serde(flatten)]
209        pub target: OriginateTarget,
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        pub dialplan: Option<DialplanType>,
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        pub context: Option<String>,
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        pub cid_name: Option<String>,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        pub cid_num: Option<String>,
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        pub timeout_secs: Option<u64>,
220    }
221
222    impl TryFrom<OriginateRaw> for Originate {
223        type Error = OriginateError;
224
225        fn try_from(raw: OriginateRaw) -> Result<Self, Self::Error> {
226            if matches!(raw.target, OriginateTarget::Extension(_))
227                && matches!(raw.dialplan, Some(DialplanType::Inline))
228            {
229                return Err(OriginateError::ExtensionWithInlineDialplan);
230            }
231            if let OriginateTarget::InlineApplications(ref apps) = raw.target {
232                if apps.is_empty() {
233                    return Err(OriginateError::EmptyInlineApplications);
234                }
235            }
236            Ok(Self {
237                endpoint: raw.endpoint,
238                target: raw.target,
239                dialplan: raw.dialplan,
240                context: raw.context,
241                cid_name: raw.cid_name,
242                cid_num: raw.cid_num,
243                timeout: raw
244                    .timeout_secs
245                    .map(Duration::from_secs),
246            })
247        }
248    }
249
250    impl From<Originate> for OriginateRaw {
251        fn from(o: Originate) -> Self {
252            Self {
253                endpoint: o.endpoint,
254                target: o.target,
255                dialplan: o.dialplan,
256                context: o.context,
257                cid_name: o.cid_name,
258                cid_num: o.cid_num,
259                timeout_secs: o
260                    .timeout
261                    .map(|d| d.as_secs()),
262            }
263        }
264    }
265
266    impl serde::Serialize for Originate {
267        fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268            OriginateRaw::from(self.clone()).serialize(serializer)
269        }
270    }
271
272    impl<'de> serde::Deserialize<'de> for Originate {
273        fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274            let raw = OriginateRaw::deserialize(deserializer)?;
275            Originate::try_from(raw).map_err(serde::de::Error::custom)
276        }
277    }
278}
279
280impl Originate {
281    /// Route through the dialplan engine to an extension.
282    pub fn extension(endpoint: Endpoint, extension: impl Into<String>) -> Self {
283        Self {
284            endpoint,
285            target: OriginateTarget::Extension(extension.into()),
286            dialplan: None,
287            context: None,
288            cid_name: None,
289            cid_num: None,
290            timeout: None,
291        }
292    }
293
294    /// Execute a single XML-format application on the answered channel.
295    pub fn application(endpoint: Endpoint, app: Application) -> Self {
296        Self {
297            endpoint,
298            target: OriginateTarget::Application(app),
299            dialplan: None,
300            context: None,
301            cid_name: None,
302            cid_num: None,
303            timeout: None,
304        }
305    }
306
307    /// Execute inline applications on the answered channel.
308    ///
309    /// Returns `Err` if the iterator yields no applications.
310    pub fn inline(
311        endpoint: Endpoint,
312        apps: impl IntoIterator<Item = Application>,
313    ) -> Result<Self, OriginateError> {
314        let apps: Vec<Application> = apps
315            .into_iter()
316            .collect();
317        if apps.is_empty() {
318            return Err(OriginateError::EmptyInlineApplications);
319        }
320        Ok(Self {
321            endpoint,
322            target: OriginateTarget::InlineApplications(apps),
323            dialplan: None,
324            context: None,
325            cid_name: None,
326            cid_num: None,
327            timeout: None,
328        })
329    }
330
331    /// Set the dialplan type.
332    ///
333    /// Returns `Err` if setting `Inline` on an `Extension` target.
334    pub fn dialplan(mut self, dp: DialplanType) -> Result<Self, OriginateError> {
335        if matches!(self.target, OriginateTarget::Extension(_)) && dp == DialplanType::Inline {
336            return Err(OriginateError::ExtensionWithInlineDialplan);
337        }
338        self.dialplan = Some(dp);
339        Ok(self)
340    }
341
342    /// Set the dialplan context.
343    pub fn context(mut self, ctx: impl Into<String>) -> Self {
344        self.context = Some(ctx.into());
345        self
346    }
347
348    /// Set the caller ID name.
349    pub fn cid_name(mut self, name: impl Into<String>) -> Self {
350        self.cid_name = Some(name.into());
351        self
352    }
353
354    /// Set the caller ID number.
355    pub fn cid_num(mut self, num: impl Into<String>) -> Self {
356        self.cid_num = Some(num.into());
357        self
358    }
359
360    /// Set the originate timeout. Sub-second precision is truncated to whole
361    /// seconds on the wire and in serde round-trips.
362    pub fn timeout(mut self, duration: Duration) -> Self {
363        self.timeout = Some(duration);
364        self
365    }
366
367    /// The dial endpoint.
368    pub fn endpoint(&self) -> &Endpoint {
369        &self.endpoint
370    }
371
372    /// Mutable reference to the dial endpoint.
373    pub fn endpoint_mut(&mut self) -> &mut Endpoint {
374        &mut self.endpoint
375    }
376
377    /// The originate target (extension, application, or inline apps).
378    pub fn target(&self) -> &OriginateTarget {
379        &self.target
380    }
381
382    /// Mutable reference to the originate target.
383    pub fn target_mut(&mut self) -> &mut OriginateTarget {
384        &mut self.target
385    }
386
387    /// The dialplan type, if explicitly set.
388    pub fn dialplan_type(&self) -> Option<&DialplanType> {
389        self.dialplan
390            .as_ref()
391    }
392
393    /// The dialplan context, if set.
394    pub fn context_str(&self) -> Option<&str> {
395        self.context
396            .as_deref()
397    }
398
399    /// The caller ID name, if set.
400    pub fn caller_id_name(&self) -> Option<&str> {
401        self.cid_name
402            .as_deref()
403    }
404
405    /// The caller ID number, if set.
406    pub fn caller_id_number(&self) -> Option<&str> {
407        self.cid_num
408            .as_deref()
409    }
410
411    /// The timeout as a `Duration`, if set.
412    pub fn timeout_duration(&self) -> Option<Duration> {
413        self.timeout
414    }
415
416    /// The timeout in whole seconds, if set.
417    pub fn timeout_seconds(&self) -> Option<u64> {
418        self.timeout
419            .map(|d| d.as_secs())
420    }
421
422    /// Override the dialplan type after construction.
423    pub fn set_dialplan(&mut self, dp: Option<DialplanType>) {
424        self.dialplan = dp;
425    }
426
427    /// Override the dialplan context after construction.
428    pub fn set_context(&mut self, ctx: Option<impl Into<String>>) {
429        self.context = ctx.map(|c| c.into());
430    }
431
432    /// Override the caller ID name after construction.
433    pub fn set_cid_name(&mut self, name: Option<impl Into<String>>) {
434        self.cid_name = name.map(|n| n.into());
435    }
436
437    /// Override the caller ID number after construction.
438    pub fn set_cid_num(&mut self, num: Option<impl Into<String>>) {
439        self.cid_num = num.map(|n| n.into());
440    }
441
442    /// Override the timeout after construction.
443    pub fn set_timeout(&mut self, timeout: Option<Duration>) {
444        self.timeout = timeout;
445    }
446}
447
448impl fmt::Display for Originate {
449    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450        let target_str = match &self.target {
451            OriginateTarget::Extension(ext) => ext.clone(),
452            OriginateTarget::Application(app) => app.to_string_with_dialplan(&DialplanType::Xml),
453            OriginateTarget::InlineApplications(apps) => {
454                // Constructor guarantees non-empty
455                let parts: Vec<String> = apps
456                    .iter()
457                    .map(|a| a.to_string_with_dialplan(&DialplanType::Inline))
458                    .collect();
459                parts.join(",")
460            }
461        };
462
463        write!(
464            f,
465            "originate {} {}",
466            self.endpoint,
467            originate_quote(&target_str)
468        )?;
469
470        // Positional args: dialplan, context, cid_name, cid_num, timeout.
471        // FreeSWITCH parses by position, so if a later arg is present,
472        // all preceding ones must be emitted with defaults.
473        let dialplan = match &self.target {
474            OriginateTarget::InlineApplications(_) => Some(
475                self.dialplan
476                    .unwrap_or(DialplanType::Inline),
477            ),
478            _ => self.dialplan,
479        };
480        let has_ctx = self
481            .context
482            .is_some();
483        let has_name = self
484            .cid_name
485            .is_some();
486        let has_num = self
487            .cid_num
488            .is_some();
489        let has_timeout = self
490            .timeout
491            .is_some();
492
493        // Positional args; FS expects the literal "UNDEF" / "default" sentinels for missing slots, not an empty string.
494        if dialplan.is_some() || has_ctx || has_name || has_num || has_timeout {
495            let dp = dialplan
496                .as_ref()
497                .cloned()
498                .unwrap_or(DialplanType::Xml);
499            write!(f, " {}", dp)?;
500        }
501        if has_ctx || has_name || has_num || has_timeout {
502            write!(
503                f,
504                " {}",
505                self.context
506                    .as_deref()
507                    .unwrap_or("default")
508            )?;
509        }
510        if has_name || has_num || has_timeout {
511            let name = self
512                .cid_name
513                .as_deref()
514                .unwrap_or(UNDEF);
515            write!(f, " {}", originate_quote(name))?;
516        }
517        if has_num || has_timeout {
518            let num = self
519                .cid_num
520                .as_deref()
521                .unwrap_or(UNDEF);
522            write!(f, " {}", originate_quote(num))?;
523        }
524        if let Some(ref timeout) = self.timeout {
525            write!(f, " {}", timeout.as_secs())?;
526        }
527        Ok(())
528    }
529}
530
531impl FromStr for Originate {
532    type Err = OriginateError;
533
534    fn from_str(s: &str) -> Result<Self, Self::Err> {
535        let s = s
536            .strip_prefix("originate")
537            .unwrap_or(s)
538            .trim();
539        let mut args = originate_split(s, ' ')?;
540
541        if args.is_empty() {
542            return Err(OriginateError::ParseError("empty originate".into()));
543        }
544
545        let endpoint_str = args.remove(0);
546        let endpoint: Endpoint = endpoint_str.parse()?;
547
548        if args.is_empty() {
549            return Err(OriginateError::ParseError(
550                "missing target in originate".into(),
551            ));
552        }
553
554        let target_str = originate_unquote(&args.remove(0));
555
556        let dialplan = args
557            .first()
558            .and_then(|s| {
559                s.parse::<DialplanType>()
560                    .ok()
561            });
562        if dialplan.is_some() {
563            args.remove(0);
564        }
565
566        let target = super::parse_originate_target(&target_str, dialplan.as_ref())?;
567
568        let context = if !args.is_empty() {
569            Some(args.remove(0))
570        } else {
571            None
572        };
573        let cid_name = if !args.is_empty() {
574            let v = args.remove(0);
575            if v.eq_ignore_ascii_case(UNDEF) {
576                None
577            } else {
578                Some(v)
579            }
580        } else {
581            None
582        };
583        let cid_num = if !args.is_empty() {
584            let v = args.remove(0);
585            if v.eq_ignore_ascii_case(UNDEF) {
586                None
587            } else {
588                Some(v)
589            }
590        } else {
591            None
592        };
593        let timeout = if !args.is_empty() {
594            Some(Duration::from_secs(
595                args.remove(0)
596                    .parse::<u64>()
597                    .map_err(|e| OriginateError::ParseError(format!("invalid timeout: {}", e)))?,
598            ))
599        } else {
600            None
601        };
602
603        // Validate via constructors then set parsed fields directly (same module)
604        let mut orig = match target {
605            OriginateTarget::Extension(ref ext) => Self::extension(endpoint, ext.clone()),
606            OriginateTarget::Application(ref app) => Self::application(endpoint, app.clone()),
607            OriginateTarget::InlineApplications(ref apps) => Self::inline(endpoint, apps.clone())?,
608        };
609        orig.dialplan = dialplan;
610        orig.context = context;
611        orig.cid_name = cid_name;
612        orig.cid_num = cid_num;
613        orig.timeout = timeout;
614        Ok(orig)
615    }
616}
617
618/// Errors from originate command parsing or construction.
619#[derive(Debug, Clone, PartialEq, Eq)]
620#[non_exhaustive]
621pub enum OriginateError {
622    /// A single-quoted token was never closed.
623    UnclosedQuote(String),
624    /// General parse failure with a description.
625    ParseError(String),
626    /// Inline originate requires at least one application.
627    EmptyInlineApplications,
628    /// Extension target cannot use inline dialplan.
629    ExtensionWithInlineDialplan,
630}
631
632impl std::fmt::Display for OriginateError {
633    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634        match self {
635            Self::UnclosedQuote(s) => write!(f, "unclosed quote at: {s}"),
636            Self::ParseError(s) => write!(f, "parse error: {s}"),
637            Self::EmptyInlineApplications => {
638                f.write_str("inline originate requires at least one application")
639            }
640            Self::ExtensionWithInlineDialplan => {
641                f.write_str("extension target is incompatible with inline dialplan")
642            }
643        }
644    }
645}
646
647impl std::error::Error for OriginateError {}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::commands::endpoint::{LoopbackEndpoint, SofiaEndpoint, SofiaGateway};
653
654    // --- Endpoint ---
655
656    #[test]
657    fn endpoint_uri_only() {
658        let ep = Endpoint::Sofia(SofiaEndpoint {
659            profile: "internal".into(),
660            destination: "123@example.com".into(),
661            variables: None,
662        });
663        assert_eq!(ep.to_string(), "sofia/internal/123@example.com");
664    }
665
666    #[test]
667    fn endpoint_uri_with_variable() {
668        let mut vars = Variables::new(VariablesType::Default);
669        vars.insert("one_variable", "1");
670        let ep = Endpoint::Sofia(SofiaEndpoint {
671            profile: "internal".into(),
672            destination: "123@example.com".into(),
673            variables: Some(vars),
674        });
675        assert_eq!(
676            ep.to_string(),
677            "{one_variable=1}sofia/internal/123@example.com"
678        );
679    }
680
681    #[test]
682    fn endpoint_variable_with_quote() {
683        let mut vars = Variables::new(VariablesType::Default);
684        vars.insert("one_variable", "one'quote");
685        let ep = Endpoint::Sofia(SofiaEndpoint {
686            profile: "internal".into(),
687            destination: "123@example.com".into(),
688            variables: Some(vars),
689        });
690        assert_eq!(
691            ep.to_string(),
692            "{one_variable=one\\'quote}sofia/internal/123@example.com"
693        );
694    }
695
696    #[test]
697    fn loopback_endpoint_display() {
698        let mut vars = Variables::new(VariablesType::Default);
699        vars.insert("one_variable", "1");
700        let ep = Endpoint::Loopback(
701            LoopbackEndpoint::new("aUri")
702                .with_context("aContext")
703                .with_variables(vars),
704        );
705        assert_eq!(ep.to_string(), "{one_variable=1}loopback/aUri/aContext");
706    }
707
708    #[test]
709    fn sofia_gateway_endpoint_display() {
710        let mut vars = Variables::new(VariablesType::Default);
711        vars.insert("one_variable", "1");
712        let ep = Endpoint::SofiaGateway(SofiaGateway {
713            destination: "aUri".into(),
714            profile: None,
715            gateway: "internal".into(),
716            variables: Some(vars),
717        });
718        assert_eq!(
719            ep.to_string(),
720            "{one_variable=1}sofia/gateway/internal/aUri"
721        );
722    }
723
724    // --- Application ---
725
726    #[test]
727    fn application_xml_format() {
728        let app = Application::new("testApp", Some("testArg"));
729        assert_eq!(
730            app.to_string_with_dialplan(&DialplanType::Xml),
731            "&testApp(testArg)"
732        );
733    }
734
735    #[test]
736    fn application_inline_format() {
737        let app = Application::new("testApp", Some("testArg"));
738        assert_eq!(
739            app.to_string_with_dialplan(&DialplanType::Inline),
740            "testApp:testArg"
741        );
742    }
743
744    #[test]
745    fn application_inline_no_args() {
746        let app = Application::simple("park");
747        assert_eq!(app.to_string_with_dialplan(&DialplanType::Inline), "park");
748    }
749
750    // --- Originate ---
751
752    #[test]
753    fn originate_xml_display() {
754        let ep = Endpoint::Sofia(SofiaEndpoint {
755            profile: "internal".into(),
756            destination: "123@example.com".into(),
757            variables: None,
758        });
759        let orig = Originate::application(ep, Application::new("conference", Some("1")))
760            .dialplan(DialplanType::Xml)
761            .unwrap();
762        assert_eq!(
763            orig.to_string(),
764            "originate sofia/internal/123@example.com &conference(1) XML"
765        );
766    }
767
768    #[test]
769    fn originate_inline_display() {
770        let ep = Endpoint::Sofia(SofiaEndpoint {
771            profile: "internal".into(),
772            destination: "123@example.com".into(),
773            variables: None,
774        });
775        let orig = Originate::inline(ep, vec![Application::new("conference", Some("1"))])
776            .unwrap()
777            .dialplan(DialplanType::Inline)
778            .unwrap();
779        assert_eq!(
780            orig.to_string(),
781            "originate sofia/internal/123@example.com conference:1 inline"
782        );
783    }
784
785    #[test]
786    fn originate_extension_display() {
787        let ep = Endpoint::Sofia(SofiaEndpoint {
788            profile: "internal".into(),
789            destination: "123@example.com".into(),
790            variables: None,
791        });
792        let orig = Originate::extension(ep, "1000")
793            .dialplan(DialplanType::Xml)
794            .unwrap()
795            .context("default");
796        assert_eq!(
797            orig.to_string(),
798            "originate sofia/internal/123@example.com 1000 XML default"
799        );
800    }
801
802    #[test]
803    fn originate_extension_round_trip() {
804        let input = "originate sofia/internal/test@example.com 1000 XML default";
805        let parsed: Originate = input
806            .parse()
807            .unwrap();
808        assert_eq!(parsed.to_string(), input);
809        assert!(matches!(parsed.target(), OriginateTarget::Extension(ref e) if e == "1000"));
810    }
811
812    #[test]
813    fn originate_extension_no_dialplan() {
814        let input = "originate sofia/internal/test@example.com 1000";
815        let parsed: Originate = input
816            .parse()
817            .unwrap();
818        assert!(matches!(parsed.target(), OriginateTarget::Extension(ref e) if e == "1000"));
819        assert_eq!(parsed.to_string(), input);
820    }
821
822    #[test]
823    fn originate_extension_with_inline_errors() {
824        let ep = Endpoint::Sofia(SofiaEndpoint {
825            profile: "internal".into(),
826            destination: "123@example.com".into(),
827            variables: None,
828        });
829        let result = Originate::extension(ep, "1000").dialplan(DialplanType::Inline);
830        assert!(result.is_err());
831    }
832
833    #[test]
834    fn originate_empty_inline_errors() {
835        let ep = Endpoint::Sofia(SofiaEndpoint {
836            profile: "internal".into(),
837            destination: "123@example.com".into(),
838            variables: None,
839        });
840        let result = Originate::inline(ep, vec![]);
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn originate_from_string_round_trip() {
846        let input = "originate {test='variable with quote'}sofia/internal/test@example.com 123";
847        let orig: Originate = input
848            .parse()
849            .unwrap();
850        assert!(matches!(orig.target(), OriginateTarget::Extension(ref e) if e == "123"));
851        assert_eq!(orig.to_string(), input);
852    }
853
854    #[test]
855    fn originate_socket_app_quoted() {
856        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("test"));
857        let orig = Originate::application(
858            ep,
859            Application::new("socket", Some("127.0.0.1:8040 async full")),
860        );
861        assert_eq!(
862            orig.to_string(),
863            "originate loopback/9199/test '&socket(127.0.0.1:8040 async full)'"
864        );
865    }
866
867    #[test]
868    fn originate_socket_round_trip() {
869        let input = "originate loopback/9199/test '&socket(127.0.0.1:8040 async full)'";
870        let parsed: Originate = input
871            .parse()
872            .unwrap();
873        assert_eq!(parsed.to_string(), input);
874        if let OriginateTarget::Application(ref app) = parsed.target() {
875            assert_eq!(app.args(), Some("127.0.0.1:8040 async full"));
876        } else {
877            panic!("expected Application target");
878        }
879    }
880
881    #[test]
882    fn originate_display_round_trip() {
883        let ep = Endpoint::Sofia(SofiaEndpoint {
884            profile: "internal".into(),
885            destination: "123@example.com".into(),
886            variables: None,
887        });
888        let orig = Originate::application(ep, Application::new("conference", Some("1")))
889            .dialplan(DialplanType::Xml)
890            .unwrap();
891        let s = orig.to_string();
892        let parsed: Originate = s
893            .parse()
894            .unwrap();
895        assert_eq!(parsed.to_string(), s);
896    }
897
898    #[test]
899    fn originate_inline_no_args_round_trip() {
900        let input = "originate sofia/internal/123@example.com park inline";
901        let parsed: Originate = input
902            .parse()
903            .unwrap();
904        assert_eq!(parsed.to_string(), input);
905        if let OriginateTarget::InlineApplications(ref apps) = parsed.target() {
906            assert!(apps[0]
907                .args()
908                .is_none());
909        } else {
910            panic!("expected InlineApplications target");
911        }
912    }
913
914    #[test]
915    fn originate_inline_multi_app_round_trip() {
916        let input =
917            "originate sofia/internal/123@example.com playback:/tmp/test.wav,hangup:NORMAL_CLEARING inline";
918        let parsed: Originate = input
919            .parse()
920            .unwrap();
921        assert_eq!(parsed.to_string(), input);
922    }
923
924    #[test]
925    fn originate_inline_auto_dialplan() {
926        let ep = Endpoint::Sofia(SofiaEndpoint {
927            profile: "internal".into(),
928            destination: "123@example.com".into(),
929            variables: None,
930        });
931        let orig = Originate::inline(ep, vec![Application::simple("park")]).unwrap();
932        assert!(orig
933            .to_string()
934            .contains("inline"));
935    }
936
937    // --- DialplanType ---
938
939    #[test]
940    fn dialplan_type_display() {
941        assert_eq!(DialplanType::Inline.to_string(), "inline");
942        assert_eq!(DialplanType::Xml.to_string(), "XML");
943    }
944
945    #[test]
946    fn dialplan_type_from_str() {
947        assert_eq!(
948            "inline"
949                .parse::<DialplanType>()
950                .unwrap(),
951            DialplanType::Inline
952        );
953        assert_eq!(
954            "XML"
955                .parse::<DialplanType>()
956                .unwrap(),
957            DialplanType::Xml
958        );
959    }
960
961    #[test]
962    fn dialplan_type_from_str_case_insensitive() {
963        assert_eq!(
964            "xml"
965                .parse::<DialplanType>()
966                .unwrap(),
967            DialplanType::Xml
968        );
969        assert_eq!(
970            "Xml"
971                .parse::<DialplanType>()
972                .unwrap(),
973            DialplanType::Xml
974        );
975        assert_eq!(
976            "INLINE"
977                .parse::<DialplanType>()
978                .unwrap(),
979            DialplanType::Inline
980        );
981        assert_eq!(
982            "Inline"
983                .parse::<DialplanType>()
984                .unwrap(),
985            DialplanType::Inline
986        );
987    }
988
989    // --- Serde ---
990
991    #[test]
992    fn serde_dialplan_type_xml() {
993        let json = serde_json::to_string(&DialplanType::Xml).unwrap();
994        assert_eq!(json, "\"xml\"");
995        let parsed: DialplanType = serde_json::from_str(&json).unwrap();
996        assert_eq!(parsed, DialplanType::Xml);
997    }
998
999    #[test]
1000    fn serde_dialplan_type_inline() {
1001        let json = serde_json::to_string(&DialplanType::Inline).unwrap();
1002        assert_eq!(json, "\"inline\"");
1003        let parsed: DialplanType = serde_json::from_str(&json).unwrap();
1004        assert_eq!(parsed, DialplanType::Inline);
1005    }
1006
1007    #[test]
1008    fn serde_application() {
1009        let app = Application::new("park", None::<&str>);
1010        let json = serde_json::to_string(&app).unwrap();
1011        let parsed: Application = serde_json::from_str(&json).unwrap();
1012        assert_eq!(parsed, app);
1013    }
1014
1015    #[test]
1016    fn serde_application_with_args() {
1017        let app = Application::new("conference", Some("1"));
1018        let json = serde_json::to_string(&app).unwrap();
1019        let parsed: Application = serde_json::from_str(&json).unwrap();
1020        assert_eq!(parsed, app);
1021    }
1022
1023    #[test]
1024    fn serde_application_skips_none_args() {
1025        let app = Application::new("park", None::<&str>);
1026        let json = serde_json::to_string(&app).unwrap();
1027        assert!(!json.contains("args"));
1028    }
1029
1030    #[test]
1031    fn serde_originate_application_round_trip() {
1032        let ep = Endpoint::Sofia(SofiaEndpoint {
1033            profile: "internal".into(),
1034            destination: "123@example.com".into(),
1035            variables: None,
1036        });
1037        let orig = Originate::application(ep, Application::new("park", None::<&str>))
1038            .dialplan(DialplanType::Xml)
1039            .unwrap()
1040            .context("default")
1041            .cid_name("Test")
1042            .cid_num("5551234")
1043            .timeout(Duration::from_secs(30));
1044        let json = serde_json::to_string(&orig).unwrap();
1045        assert!(json.contains("\"application\""));
1046        let parsed: Originate = serde_json::from_str(&json).unwrap();
1047        assert_eq!(parsed, orig);
1048    }
1049
1050    #[test]
1051    fn serde_originate_extension() {
1052        let json = r#"{
1053            "endpoint": {"sofia": {"profile": "internal", "destination": "123@example.com"}},
1054            "extension": "1000",
1055            "dialplan": "xml",
1056            "context": "default"
1057        }"#;
1058        let orig: Originate = serde_json::from_str(json).unwrap();
1059        assert!(matches!(orig.target(), OriginateTarget::Extension(ref e) if e == "1000"));
1060        assert_eq!(
1061            orig.to_string(),
1062            "originate sofia/internal/123@example.com 1000 XML default"
1063        );
1064    }
1065
1066    #[test]
1067    fn serde_originate_extension_with_inline_rejected() {
1068        let json = r#"{
1069            "endpoint": {"sofia": {"profile": "internal", "destination": "123@example.com"}},
1070            "extension": "1000",
1071            "dialplan": "inline"
1072        }"#;
1073        let result = serde_json::from_str::<Originate>(json);
1074        assert!(result.is_err());
1075    }
1076
1077    #[test]
1078    fn serde_originate_empty_inline_rejected() {
1079        let json = r#"{
1080            "endpoint": {"sofia": {"profile": "internal", "destination": "123@example.com"}},
1081            "inline_applications": []
1082        }"#;
1083        let result = serde_json::from_str::<Originate>(json);
1084        assert!(result.is_err());
1085    }
1086
1087    #[test]
1088    fn serde_originate_inline_applications() {
1089        let json = r#"{
1090            "endpoint": {"sofia": {"profile": "internal", "destination": "123@example.com"}},
1091            "inline_applications": [
1092                {"name": "playback", "args": "/tmp/test.wav"},
1093                {"name": "hangup", "args": "NORMAL_CLEARING"}
1094            ]
1095        }"#;
1096        let orig: Originate = serde_json::from_str(json).unwrap();
1097        if let OriginateTarget::InlineApplications(ref apps) = orig.target() {
1098            assert_eq!(apps.len(), 2);
1099        } else {
1100            panic!("expected InlineApplications");
1101        }
1102        assert!(orig
1103            .to_string()
1104            .contains("inline"));
1105    }
1106
1107    #[test]
1108    fn serde_originate_skips_none_fields() {
1109        let ep = Endpoint::Sofia(SofiaEndpoint {
1110            profile: "internal".into(),
1111            destination: "123@example.com".into(),
1112            variables: None,
1113        });
1114        let orig = Originate::application(ep, Application::new("park", None::<&str>));
1115        let json = serde_json::to_string(&orig).unwrap();
1116        assert!(!json.contains("dialplan"));
1117        assert!(!json.contains("context"));
1118        assert!(!json.contains("cid_name"));
1119        assert!(!json.contains("cid_num"));
1120        assert!(!json.contains("timeout"));
1121    }
1122
1123    #[test]
1124    fn serde_originate_to_wire_format() {
1125        let json = r#"{
1126            "endpoint": {"sofia": {"profile": "internal", "destination": "123@example.com"}},
1127            "application": {"name": "park"},
1128            "dialplan": "xml",
1129            "context": "default"
1130        }"#;
1131        let orig: Originate = serde_json::from_str(json).unwrap();
1132        let wire = orig.to_string();
1133        assert!(wire.starts_with("originate"));
1134        assert!(wire.contains("sofia/internal/123@example.com"));
1135        assert!(wire.contains("&park()"));
1136        assert!(wire.contains("XML"));
1137    }
1138
1139    // --- Application::simple ---
1140
1141    #[test]
1142    fn application_simple_no_args() {
1143        let app = Application::simple("park");
1144        assert_eq!(app.name(), "park");
1145        assert!(app
1146            .args()
1147            .is_none());
1148    }
1149
1150    #[test]
1151    fn application_simple_xml_format() {
1152        let app = Application::simple("park");
1153        assert_eq!(app.to_string_with_dialplan(&DialplanType::Xml), "&park()");
1154    }
1155
1156    // --- OriginateTarget From impls ---
1157
1158    #[test]
1159    fn originate_target_from_application() {
1160        let target: OriginateTarget = Application::simple("park").into();
1161        assert!(matches!(target, OriginateTarget::Application(_)));
1162    }
1163
1164    #[test]
1165    fn originate_target_from_vec() {
1166        let target: OriginateTarget = vec![
1167            Application::new("conference", Some("1")),
1168            Application::new("hangup", Some("NORMAL_CLEARING")),
1169        ]
1170        .into();
1171        if let OriginateTarget::InlineApplications(apps) = target {
1172            assert_eq!(apps.len(), 2);
1173        } else {
1174            panic!("expected InlineApplications");
1175        }
1176    }
1177
1178    #[test]
1179    fn originate_target_application_wire_format() {
1180        let ep = Endpoint::Sofia(SofiaEndpoint {
1181            profile: "internal".into(),
1182            destination: "123@example.com".into(),
1183            variables: None,
1184        });
1185        let orig = Originate::application(ep, Application::simple("park"));
1186        assert_eq!(
1187            orig.to_string(),
1188            "originate sofia/internal/123@example.com &park()"
1189        );
1190    }
1191
1192    #[test]
1193    fn originate_timeout_only_fills_positional_gaps() {
1194        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("test"));
1195        let cmd = Originate::application(ep, Application::simple("park"))
1196            .timeout(Duration::from_secs(30));
1197        // timeout is arg 7; dialplan/context/cid must be filled so FS
1198        // doesn't interpret "30" as the dialplan name
1199        assert_eq!(
1200            cmd.to_string(),
1201            "originate loopback/9199/test &park() XML default undef undef 30"
1202        );
1203    }
1204
1205    #[test]
1206    fn originate_cid_num_only_fills_preceding_gaps() {
1207        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("test"));
1208        let cmd = Originate::application(ep, Application::simple("park")).cid_num("5551234");
1209        assert_eq!(
1210            cmd.to_string(),
1211            "originate loopback/9199/test &park() XML default undef 5551234"
1212        );
1213    }
1214
1215    #[test]
1216    fn originate_context_only_fills_dialplan() {
1217        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("test"));
1218        let cmd = Originate::extension(ep, "1000").context("myctx");
1219        assert_eq!(
1220            cmd.to_string(),
1221            "originate loopback/9199/test 1000 XML myctx"
1222        );
1223    }
1224
1225    /// `context: None` with a later positional arg emits `"default"` as a
1226    /// gap-filler. `FromStr` reads it back as `Some("default")` because
1227    /// `"default"` is also a valid user-specified context. The wire format
1228    /// round-trips correctly (identical string), but the struct-level
1229    /// representation differs. This is an accepted asymmetry.
1230    #[test]
1231    fn originate_context_gap_filler_round_trip_asymmetry() {
1232        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("test"));
1233        let cmd = Originate::application(ep, Application::simple("park")).cid_name("Alice");
1234        let wire = cmd.to_string();
1235        assert!(wire.contains("default"), "gap-filler should emit 'default'");
1236
1237        let parsed: Originate = wire
1238            .parse()
1239            .unwrap();
1240        // Struct-level asymmetry: None became Some("default")
1241        assert_eq!(parsed.context_str(), Some("default"));
1242
1243        // Wire format is identical (the important invariant)
1244        assert_eq!(parsed.to_string(), wire);
1245    }
1246
1247    // --- T1: Full Originate serde round-trip ---
1248
1249    #[test]
1250    fn serde_originate_full_round_trip_with_variables() {
1251        let mut ep_vars = Variables::new(VariablesType::Default);
1252        ep_vars.insert("originate_timeout", "30");
1253        ep_vars.insert("sip_h_X-Custom", "value with spaces");
1254        let ep = Endpoint::SofiaGateway(SofiaGateway {
1255            gateway: "my_provider".into(),
1256            destination: "18005551234".into(),
1257            profile: Some("external".into()),
1258            variables: Some(ep_vars),
1259        });
1260        let orig = Originate::application(ep, Application::new("park", None::<&str>))
1261            .dialplan(DialplanType::Xml)
1262            .unwrap()
1263            .context("public")
1264            .cid_name("Test Caller")
1265            .cid_num("5551234")
1266            .timeout(Duration::from_secs(60));
1267        let json = serde_json::to_string(&orig).unwrap();
1268        let parsed: Originate = serde_json::from_str(&json).unwrap();
1269        assert_eq!(parsed, orig);
1270        assert_eq!(parsed.to_string(), orig.to_string());
1271    }
1272
1273    #[test]
1274    fn serde_originate_inline_round_trip_with_all_fields() {
1275        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("default"));
1276        let orig = Originate::inline(
1277            ep,
1278            vec![
1279                Application::new("playback", Some("/tmp/test.wav")),
1280                Application::new("hangup", Some("NORMAL_CLEARING")),
1281            ],
1282        )
1283        .unwrap()
1284        .dialplan(DialplanType::Inline)
1285        .unwrap()
1286        .context("default")
1287        .cid_name("IVR")
1288        .cid_num("0000")
1289        .timeout(Duration::from_secs(45));
1290        let json = serde_json::to_string(&orig).unwrap();
1291        let parsed: Originate = serde_json::from_str(&json).unwrap();
1292        assert_eq!(parsed, orig);
1293        assert_eq!(parsed.to_string(), orig.to_string());
1294    }
1295
1296    // --- T5: Originate::from_str with context named "inline" or "XML" ---
1297
1298    #[test]
1299    fn originate_context_named_inline() {
1300        let ep = Endpoint::Sofia(SofiaEndpoint {
1301            profile: "internal".into(),
1302            destination: "123@example.com".into(),
1303            variables: None,
1304        });
1305        let orig = Originate::extension(ep, "1000")
1306            .dialplan(DialplanType::Xml)
1307            .unwrap()
1308            .context("inline");
1309        let wire = orig.to_string();
1310        assert!(wire.contains("XML inline"), "wire: {}", wire);
1311        let parsed: Originate = wire
1312            .parse()
1313            .unwrap();
1314        // "inline" is consumed as the dialplan type, not the context
1315        // This is an accepted limitation of positional parsing
1316        assert_eq!(parsed.to_string(), wire);
1317    }
1318
1319    #[test]
1320    fn originate_context_named_xml() {
1321        let ep = Endpoint::Sofia(SofiaEndpoint {
1322            profile: "internal".into(),
1323            destination: "123@example.com".into(),
1324            variables: None,
1325        });
1326        let orig = Originate::extension(ep, "1000")
1327            .dialplan(DialplanType::Xml)
1328            .unwrap()
1329            .context("XML");
1330        let wire = orig.to_string();
1331        // "XML XML" - first is dialplan, second is context
1332        assert!(wire.contains("XML XML"), "wire: {}", wire);
1333        let parsed: Originate = wire
1334            .parse()
1335            .unwrap();
1336        assert_eq!(parsed.to_string(), wire);
1337    }
1338
1339    #[test]
1340    fn originate_accessors() {
1341        let ep = Endpoint::Loopback(LoopbackEndpoint::new("9199").with_context("default"));
1342        let cmd = Originate::extension(ep, "1000")
1343            .dialplan(DialplanType::Xml)
1344            .unwrap()
1345            .context("default")
1346            .cid_name("Alice")
1347            .cid_num("5551234")
1348            .timeout(Duration::from_secs(30));
1349
1350        assert!(matches!(cmd.target(), OriginateTarget::Extension(ref e) if e == "1000"));
1351        assert_eq!(cmd.dialplan_type(), Some(&DialplanType::Xml));
1352        assert_eq!(cmd.context_str(), Some("default"));
1353        assert_eq!(cmd.caller_id_name(), Some("Alice"));
1354        assert_eq!(cmd.caller_id_number(), Some("5551234"));
1355        assert_eq!(cmd.timeout_seconds(), Some(30));
1356    }
1357}