Skip to main content

freeswitch_types/
channel.rs

1//! Channel-related data types extracted from ESL event headers.
2
3use std::fmt;
4
5wire_enum! {
6    /// Channel state from `switch_channel_state_t` -- carried in the `Channel-State` header
7    /// as a string (`CS_ROUTING`) and in `Channel-State-Number` as an integer.
8    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9    #[repr(u8)]
10    pub enum ChannelState {
11        CsNew = 0 => "CS_NEW",
12        CsInit = 1 => "CS_INIT",
13        CsRouting = 2 => "CS_ROUTING",
14        CsSoftExecute = 3 => "CS_SOFT_EXECUTE",
15        CsExecute = 4 => "CS_EXECUTE",
16        CsExchangeMedia = 5 => "CS_EXCHANGE_MEDIA",
17        CsPark = 6 => "CS_PARK",
18        CsConsumeMedia = 7 => "CS_CONSUME_MEDIA",
19        CsHibernate = 8 => "CS_HIBERNATE",
20        CsReset = 9 => "CS_RESET",
21        CsHangup = 10 => "CS_HANGUP",
22        CsReporting = 11 => "CS_REPORTING",
23        CsDestroy = 12 => "CS_DESTROY",
24        CsNone = 13 => "CS_NONE",
25    }
26    error ParseChannelStateError("channel state");
27    numeric: from_number(u8);
28    tests: channel_state_wire_tests;
29}
30
31impl ChannelState {
32    /// Whether the channel is gone: `CS_DESTROY`, and nothing else.
33    ///
34    /// The session's run loop exits on `CS_DESTROY`; `CS_NONE` is a
35    /// `running_state` sentinel the state machine aborts on if it ever reaches
36    /// it, so it ends nothing despite sorting above `CS_DESTROY`.
37    ///
38    /// `CHANNEL_STATE` events carry no `variable_*` headers, so end-of-life
39    /// detected here cannot be paired with any channel variable from the same
40    /// event. The variable-bearing terminal event is `CHANNEL_HANGUP_COMPLETE`,
41    /// which is also where `variable_hangup_cause` first appears --
42    /// `CHANNEL_HANGUP` carries only the `Hangup-Cause` header.
43    pub fn is_terminal(&self) -> bool {
44        matches!(self, Self::CsDestroy)
45    }
46}
47
48/// Endpoint module a channel name belongs to: the segment before the first `/`
49/// (`sofia`, `loopback`, `null`, ...).
50///
51/// No enum, because any module can register an endpoint. A channel name is also
52/// what the channel calls itself rather than proof of its driver -- see
53/// [`LoopbackChannelName`](crate::variables::LoopbackChannelName) for what that
54/// costs after a loopback resignation.
55pub fn channel_driver(name: &str) -> Option<&str> {
56    let (driver, _) = name.split_once('/')?;
57    if driver.is_empty() {
58        return None;
59    }
60    Some(driver)
61}
62
63wire_enum! {
64    /// Call state from `switch_channel_callstate_t` -- carried in the `Channel-Call-State` header.
65    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
66    pub enum CallState {
67        Down => "DOWN",
68        Dialing => "DIALING",
69        Ringing => "RINGING",
70        Early => "EARLY",
71        Active => "ACTIVE",
72        Held => "HELD",
73        RingWait => "RING_WAIT",
74        Hangup => "HANGUP",
75        Unheld => "UNHELD",
76    }
77    error ParseCallStateError("call state");
78    tests: call_state_wire_tests;
79}
80
81wire_enum! {
82    /// Answer state from the `Answer-State` header. Wire format is lowercase.
83    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84    pub enum AnswerState {
85        Hangup => "hangup",
86        Answered => "answered",
87        Early => "early",
88        Ringing => "ringing",
89    }
90    error ParseAnswerStateError("answer state");
91    tests: answer_state_wire_tests;
92}
93
94wire_enum! {
95    /// Call direction from the `Call-Direction` header. Wire format is lowercase.
96    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97    pub enum CallDirection {
98        Inbound => "inbound",
99        Outbound => "outbound",
100    }
101    error ParseCallDirectionError("call direction");
102    tests: call_direction_wire_tests;
103}
104
105wire_enum! {
106    /// Hangup cause from `switch_cause_t` (Q.850 + FreeSWITCH extensions).
107    ///
108    /// Carried in the `Hangup-Cause` header. Wire format is `SCREAMING_SNAKE_CASE`
109    /// (e.g. `NORMAL_CLEARING`). The numeric value matches the Q.850 cause code
110    /// for standard causes, or a FreeSWITCH-internal range for extensions.
111    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112    #[repr(u16)]
113    pub enum HangupCause {
114        None = 0 => "NONE",
115        UnallocatedNumber = 1 => "UNALLOCATED_NUMBER",
116        NoRouteTransitNet = 2 => "NO_ROUTE_TRANSIT_NET",
117        NoRouteDestination = 3 => "NO_ROUTE_DESTINATION",
118        ChannelUnacceptable = 6 => "CHANNEL_UNACCEPTABLE",
119        CallAwardedDelivered = 7 => "CALL_AWARDED_DELIVERED",
120        NormalClearing = 16 => "NORMAL_CLEARING",
121        UserBusy = 17 => "USER_BUSY",
122        NoUserResponse = 18 => "NO_USER_RESPONSE",
123        NoAnswer = 19 => "NO_ANSWER",
124        SubscriberAbsent = 20 => "SUBSCRIBER_ABSENT",
125        CallRejected = 21 => "CALL_REJECTED",
126        NumberChanged = 22 => "NUMBER_CHANGED",
127        RedirectionToNewDestination = 23 => "REDIRECTION_TO_NEW_DESTINATION",
128        ExchangeRoutingError = 25 => "EXCHANGE_ROUTING_ERROR",
129        DestinationOutOfOrder = 27 => "DESTINATION_OUT_OF_ORDER",
130        InvalidNumberFormat = 28 => "INVALID_NUMBER_FORMAT",
131        FacilityRejected = 29 => "FACILITY_REJECTED",
132        ResponseToStatusEnquiry = 30 => "RESPONSE_TO_STATUS_ENQUIRY",
133        NormalUnspecified = 31 => "NORMAL_UNSPECIFIED",
134        NormalCircuitCongestion = 34 => "NORMAL_CIRCUIT_CONGESTION",
135        NetworkOutOfOrder = 38 => "NETWORK_OUT_OF_ORDER",
136        NormalTemporaryFailure = 41 => "NORMAL_TEMPORARY_FAILURE",
137        SwitchCongestion = 42 => "SWITCH_CONGESTION",
138        AccessInfoDiscarded = 43 => "ACCESS_INFO_DISCARDED",
139        RequestedChanUnavail = 44 => "REQUESTED_CHAN_UNAVAIL",
140        PreEmpted = 45 => "PRE_EMPTED",
141        FacilityNotSubscribed = 50 => "FACILITY_NOT_SUBSCRIBED",
142        OutgoingCallBarred = 52 => "OUTGOING_CALL_BARRED",
143        IncomingCallBarred = 54 => "INCOMING_CALL_BARRED",
144        BearercapabilityNotauth = 57 => "BEARERCAPABILITY_NOTAUTH",
145        BearercapabilityNotavail = 58 => "BEARERCAPABILITY_NOTAVAIL",
146        ServiceUnavailable = 63 => "SERVICE_UNAVAILABLE",
147        BearercapabilityNotimpl = 65 => "BEARERCAPABILITY_NOTIMPL",
148        ChanNotImplemented = 66 => "CHAN_NOT_IMPLEMENTED",
149        FacilityNotImplemented = 69 => "FACILITY_NOT_IMPLEMENTED",
150        ServiceNotImplemented = 79 => "SERVICE_NOT_IMPLEMENTED",
151        InvalidCallReference = 81 => "INVALID_CALL_REFERENCE",
152        IncompatibleDestination = 88 => "INCOMPATIBLE_DESTINATION",
153        InvalidMsgUnspecified = 95 => "INVALID_MSG_UNSPECIFIED",
154        MandatoryIeMissing = 96 => "MANDATORY_IE_MISSING",
155        MessageTypeNonexist = 97 => "MESSAGE_TYPE_NONEXIST",
156        WrongMessage = 98 => "WRONG_MESSAGE",
157        IeNonexist = 99 => "IE_NONEXIST",
158        InvalidIeContents = 100 => "INVALID_IE_CONTENTS",
159        WrongCallState = 101 => "WRONG_CALL_STATE",
160        RecoveryOnTimerExpire = 102 => "RECOVERY_ON_TIMER_EXPIRE",
161        MandatoryIeLengthError = 103 => "MANDATORY_IE_LENGTH_ERROR",
162        ProtocolError = 111 => "PROTOCOL_ERROR",
163        Interworking = 127 => "INTERWORKING",
164        Success = 142 => "SUCCESS",
165        OriginatorCancel = 487 => "ORIGINATOR_CANCEL",
166        Crash = 700 => "CRASH",
167        SystemShutdown = 701 => "SYSTEM_SHUTDOWN",
168        LoseRace = 702 => "LOSE_RACE",
169        ManagerRequest = 703 => "MANAGER_REQUEST",
170        BlindTransfer = 800 => "BLIND_TRANSFER",
171        AttendedTransfer = 801 => "ATTENDED_TRANSFER",
172        AllottedTimeout = 802 => "ALLOTTED_TIMEOUT",
173        UserChallenge = 803 => "USER_CHALLENGE",
174        MediaTimeout = 804 => "MEDIA_TIMEOUT",
175        PickedOff = 805 => "PICKED_OFF",
176        UserNotRegistered = 806 => "USER_NOT_REGISTERED",
177        ProgressTimeout = 807 => "PROGRESS_TIMEOUT",
178        InvalidGateway = 808 => "INVALID_GATEWAY",
179        GatewayDown = 809 => "GATEWAY_DOWN",
180        InvalidUrl = 810 => "INVALID_URL",
181        InvalidProfile = 811 => "INVALID_PROFILE",
182        NoPickup = 812 => "NO_PICKUP",
183        SrtpReadError = 813 => "SRTP_READ_ERROR",
184        Bowout = 814 => "BOWOUT",
185        BusyEverywhere = 815 => "BUSY_EVERYWHERE",
186        Decline = 816 => "DECLINE",
187        DoesNotExistAnywhere = 817 => "DOES_NOT_EXIST_ANYWHERE",
188        NotAcceptable = 818 => "NOT_ACCEPTABLE",
189        Unwanted = 819 => "UNWANTED",
190        NoIdentity = 820 => "NO_IDENTITY",
191        BadIdentityInfo = 821 => "BAD_IDENTITY_INFO",
192        UnsupportedCertificate = 822 => "UNSUPPORTED_CERTIFICATE",
193        InvalidIdentity = 823 => "INVALID_IDENTITY",
194        /// Stale Date (STIR/SHAKEN).
195        StaleDate = 824 => "STALE_DATE",
196        /// Reject all calls.
197        RejectAll = 825 => "REJECT_ALL",
198    }
199    error ParseHangupCauseError("hangup cause");
200    numeric: from_number(u16);
201    tests: hangup_cause_wire_tests;
202}
203
204impl HangupCause {
205    /// Map a SIP response code to the corresponding FreeSWITCH hangup cause.
206    ///
207    /// Uses the same mapping as mod_sofia's `sofia_glue_sip_cause_to_freeswitch()`.
208    /// Returns `None` for codes without an explicit mapping.
209    pub fn from_sip_response(code: u16) -> Option<Self> {
210        match code {
211            200 => Some(Self::NormalClearing),
212            401 | 402 | 403 | 407 | 603 | 608 => Some(Self::CallRejected),
213            607 => Some(Self::Unwanted),
214            404 => Some(Self::UnallocatedNumber),
215            485 | 604 => Some(Self::NoRouteDestination),
216            408 | 504 => Some(Self::RecoveryOnTimerExpire),
217            410 => Some(Self::NumberChanged),
218            413 | 414 | 416 | 420 | 421 | 423 | 505 | 513 => Some(Self::Interworking),
219            480 => Some(Self::NoUserResponse),
220            400 | 481 | 500 | 503 => Some(Self::NormalTemporaryFailure),
221            486 | 600 => Some(Self::UserBusy),
222            484 => Some(Self::InvalidNumberFormat),
223            488 | 606 => Some(Self::IncompatibleDestination),
224            502 => Some(Self::NetworkOutOfOrder),
225            405 => Some(Self::ServiceUnavailable),
226            406 | 415 | 501 => Some(Self::ServiceNotImplemented),
227            482 | 483 => Some(Self::ExchangeRoutingError),
228            487 => Some(Self::OriginatorCancel),
229            428 => Some(Self::NoIdentity),
230            429 => Some(Self::BadIdentityInfo),
231            437 => Some(Self::UnsupportedCertificate),
232            438 => Some(Self::InvalidIdentity),
233            _ => None,
234        }
235    }
236}
237
238/// Channel timing data from FreeSWITCH's `switch_channel_timetable_t`.
239///
240/// Timestamps are epoch microseconds (`i64`). A value of `0` means the
241/// corresponding event never occurred (e.g., `hungup == Some(0)` means
242/// the channel has not hung up yet). `None` means the header was absent
243/// or unparseable.
244///
245/// Extracted from ESL event headers using a prefix (typically `"Caller"`
246/// or `"Other-Leg"`). The wire header format is `{prefix}-{suffix}`.
247///
248/// ## Headers extracted
249///
250/// `from_lookup()` extracts headers with suffixes from [`SUFFIXES`](Self::SUFFIXES).
251/// With `TimetablePrefix::Caller`, extracts `Caller-Channel-Created-Time` →
252/// `created`, `Caller-Channel-Hangup-Time` → `hungup`, etc.
253///
254/// Use `TimetablePrefix::OtherLeg` for `Other-Leg-*` headers,
255/// `TimetablePrefix::Channel` for outbound ESL `Channel-*` headers, or pass
256/// a custom string prefix to `from_lookup()`. See [`SUFFIXES`](Self::SUFFIXES)
257/// for the complete list.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
259#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
260#[non_exhaustive]
261pub struct ChannelTimetable {
262    /// When the caller profile was created.
263    pub profile_created: Option<i64>,
264    /// When the channel was created.
265    pub created: Option<i64>,
266    /// When the channel was answered.
267    pub answered: Option<i64>,
268    /// When early media (183) was received.
269    pub progress: Option<i64>,
270    /// When media-bearing early media arrived.
271    pub progress_media: Option<i64>,
272    /// When the channel hung up.
273    pub hungup: Option<i64>,
274    /// When the channel was transferred.
275    pub transferred: Option<i64>,
276    /// When the channel was resurrected.
277    pub resurrected: Option<i64>,
278    /// When the channel was bridged.
279    pub bridged: Option<i64>,
280    /// Timestamp of the last hold event.
281    pub last_hold: Option<i64>,
282    /// Accumulated hold time in microseconds.
283    pub hold_accum: Option<i64>,
284}
285
286/// Header prefix identifying which call leg's timetable to extract.
287///
288/// FreeSWITCH emits timetable headers as `{prefix}-Channel-Created-Time`, etc.
289/// The prefix varies by context -- `Caller` for the primary leg, `Other-Leg`
290/// for the bridged party, `Channel` in outbound ESL mode, etc.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
293#[non_exhaustive]
294pub enum TimetablePrefix {
295    /// Primary call leg (`Caller-*`).
296    Caller,
297    /// Bridged party (`Other-Leg-*`).
298    OtherLeg,
299    /// Outbound ESL channel profile (`Channel-*`).
300    Channel,
301    /// XML dialplan hunt (`Hunt-*`).
302    Hunt,
303    /// Bridge debug originator (`ORIGINATOR-*`).
304    Originator,
305    /// Bridge debug originatee (`ORIGINATEE-*`).
306    Originatee,
307    /// Post-bridge debug originator (`POST-ORIGINATOR-*`).
308    PostOriginator,
309    /// Post-bridge debug originatee (`POST-ORIGINATEE-*`).
310    PostOriginatee,
311}
312
313impl TimetablePrefix {
314    /// Wire-format prefix string.
315    pub fn as_str(&self) -> &'static str {
316        match self {
317            Self::Caller => "Caller",
318            Self::OtherLeg => "Other-Leg",
319            Self::Channel => "Channel",
320            Self::Hunt => "Hunt",
321            Self::Originator => "ORIGINATOR",
322            Self::Originatee => "ORIGINATEE",
323            Self::PostOriginator => "POST-ORIGINATOR",
324            Self::PostOriginatee => "POST-ORIGINATEE",
325        }
326    }
327}
328
329impl fmt::Display for TimetablePrefix {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        f.write_str(self.as_str())
332    }
333}
334
335impl AsRef<str> for TimetablePrefix {
336    fn as_ref(&self) -> &str {
337        self.as_str()
338    }
339}
340
341/// Error returned when a timetable header is present but not a valid `i64`.
342#[derive(Debug, Clone, PartialEq, Eq)]
343#[non_exhaustive]
344pub struct ParseTimetableError {
345    /// Full header name (e.g. `Caller-Channel-Created-Time`).
346    pub header: String,
347    /// The unparseable value found in the header.
348    pub value: String,
349}
350
351impl fmt::Display for ParseTimetableError {
352    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353        write!(
354            f,
355            "invalid timetable value for {} ({} bytes)",
356            self.header,
357            self.value
358                .len()
359        )
360    }
361}
362
363impl std::error::Error for ParseTimetableError {}
364
365impl ParseTimetableError {
366    /// Create a new timetable parse error.
367    pub fn new(header: impl Into<String>, value: impl Into<String>) -> Self {
368        Self {
369            header: header.into(),
370            value: value.into(),
371        }
372    }
373}
374
375/// Generate [`TimetableField`], `ChannelTimetable::SUFFIXES` and
376/// `ChannelTimetable::from_lookup` from a single
377/// `(field => Variant => "Suffix")` table, eliminating the risk of drift between
378/// the named fields, the advertised suffix list and the headers actually
379/// extracted.
380macro_rules! channel_timetable_fields {
381    ($($field:ident => $Variant:ident => $suffix:literal),+ $(,)?) => {
382        /// One timetable header, named rather than positional.
383        ///
384        /// [`header_name()`](Self::header_name) composes the wire header a
385        /// consumer wanting a single timestamp has to subscribe to, filter on or
386        /// look up. The alternative is indexing
387        /// [`ChannelTimetable::SUFFIXES`](ChannelTimetable::SUFFIXES) by
388        /// position, which changes meaning if the table is ever reordered.
389        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
390        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
391        #[non_exhaustive]
392        pub enum TimetableField {
393            $(
394                #[doc = concat!("`", $suffix, "`, into [`ChannelTimetable::", stringify!($field), "`].")]
395                $Variant,
396            )+
397        }
398
399        impl TimetableField {
400            /// Header suffix, without any prefix.
401            pub fn as_str(&self) -> &'static str {
402                match self {
403                    $(Self::$Variant => $suffix,)+
404                }
405            }
406
407            /// Full wire header name for a call leg.
408            ///
409            /// Takes the same prefix forms as
410            /// [`ChannelTimetable::from_lookup`](ChannelTimetable::from_lookup):
411            /// a [`TimetablePrefix`] or a raw string for the dynamic `Call-<n>`
412            /// prefixes.
413            ///
414            /// ```
415            /// use freeswitch_types::{TimetableField, TimetablePrefix};
416            ///
417            /// assert_eq!(
418            ///     TimetableField::Answered.header_name(TimetablePrefix::OtherLeg),
419            ///     "Other-Leg-Channel-Answered-Time"
420            /// );
421            /// ```
422            pub fn header_name(&self, prefix: impl AsRef<str>) -> String {
423                format!("{}-{}", prefix.as_ref(), self.as_str())
424            }
425        }
426
427        impl ChannelTimetable {
428        /// Header suffixes extracted by `from_lookup()`.
429        ///
430        /// Combine with a `TimetablePrefix` to build full header names for event
431        /// subscriptions or filters:
432        ///
433        /// ```
434        /// use freeswitch_types::{ChannelTimetable, TimetablePrefix};
435        ///
436        /// let prefix = TimetablePrefix::Caller.as_str();
437        /// let headers: Vec<String> = ChannelTimetable::SUFFIXES
438        ///     .iter()
439        ///     .map(|suffix| format!("{prefix}-{suffix}"))
440        ///     .collect();
441        /// assert!(headers.contains(&"Caller-Channel-Created-Time".to_string()));
442        /// ```
443        pub const SUFFIXES: &'static [&'static str] = &[$($suffix),+];
444
445        /// Extract a timetable by looking up prefixed header names via a closure.
446        ///
447        /// The closure receives full header names (e.g. `"Caller-Channel-Created-Time"`)
448        /// and should return the raw value if present. Works with any key-value store:
449        /// `HashMap<String, String>`, `EslEvent`, `BTreeMap`, etc.
450        ///
451        /// Returns `Ok(None)` if no timestamp headers with this prefix are present.
452        /// Returns `Err` if a header is present but contains an invalid (non-`i64`) value.
453        ///
454        /// ```
455        /// use std::collections::HashMap;
456        /// use freeswitch_types::{ChannelTimetable, TimetablePrefix};
457        ///
458        /// let mut headers: HashMap<String, String> = HashMap::new();
459        /// headers.insert("Caller-Channel-Created-Time".into(), "1700000001000000".into());
460        ///
461        /// // With enum:
462        /// let tt = ChannelTimetable::from_lookup(TimetablePrefix::Caller, |k| headers.get(k).map(|v: &String| v.as_str()));
463        /// assert!(tt.unwrap().unwrap().created.is_some());
464        ///
465        /// // With raw string (e.g. for dynamic "Call-1" prefix):
466        /// let tt = ChannelTimetable::from_lookup("Caller", |k| headers.get(k).map(|v: &String| v.as_str()));
467        /// assert!(tt.unwrap().unwrap().created.is_some());
468        /// ```
469        pub fn from_lookup<'a>(
470            prefix: impl AsRef<str>,
471            lookup: impl Fn(&str) -> Option<&'a str>,
472        ) -> Result<Option<Self>, ParseTimetableError> {
473            let prefix = prefix.as_ref();
474            let mut tt = Self::default();
475            let mut found = false;
476            $(
477                {
478                    let header = TimetableField::$Variant.header_name(prefix);
479                    if let Some(raw) = lookup(&header) {
480                        let v: i64 = raw.parse().map_err(|_| ParseTimetableError {
481                            header: header.clone(),
482                            value: raw.to_string(),
483                        })?;
484                        tt.$field = Some(v);
485                        found = true;
486                    }
487                }
488            )+
489            if found {
490                Ok(Some(tt))
491            } else {
492                Ok(None)
493            }
494        }
495        }
496    };
497}
498
499channel_timetable_fields! {
500    profile_created => ProfileCreated => "Profile-Created-Time",
501    created         => Created        => "Channel-Created-Time",
502    answered        => Answered       => "Channel-Answered-Time",
503    progress        => Progress       => "Channel-Progress-Time",
504    progress_media  => ProgressMedia  => "Channel-Progress-Media-Time",
505    hungup          => Hungup         => "Channel-Hangup-Time",
506    transferred     => Transferred    => "Channel-Transfer-Time",
507    resurrected     => Resurrected    => "Channel-Resurrect-Time",
508    bridged         => Bridged        => "Channel-Bridged-Time",
509    last_hold       => LastHold       => "Channel-Last-Hold",
510    hold_accum      => HoldAccum      => "Channel-Hold-Accum",
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    // --- ChannelState tests (display / from_str / wrong-case / unknown
518    //     are generated by the `tests: channel_state_wire_tests` clause
519    //     on the wire_enum! invocation above). ---
520
521    #[test]
522    fn channel_driver_is_the_segment_before_the_first_slash() {
523        assert_eq!(
524            channel_driver("sofia/internal/1000@example.com"),
525            Some("sofia")
526        );
527        assert_eq!(channel_driver("loopback/9199-a"), Some("loopback"));
528        assert_eq!(channel_driver("null/9199"), Some("null"));
529    }
530
531    #[test]
532    fn channel_driver_none_without_a_segment() {
533        for name in ["", "sofia", "/internal/1000"] {
534            assert_eq!(channel_driver(name), None, "{name:?}");
535        }
536    }
537
538    #[test]
539    fn timetable_error_display_omits_the_value() {
540        let err = ParseTimetableError::new("Caller-Channel-Created-Time", "5551234567");
541        let msg = err.to_string();
542        assert!(!msg.contains("5551234567"), "error quoted its input: {msg}");
543        assert!(msg.contains("Caller-Channel-Created-Time"), "{msg}");
544        assert_eq!(err.value, "5551234567");
545    }
546
547    #[test]
548    fn test_channel_state_from_number() {
549        assert_eq!(ChannelState::from_number(0), Some(ChannelState::CsNew));
550        assert_eq!(ChannelState::from_number(4), Some(ChannelState::CsExecute));
551        assert_eq!(ChannelState::from_number(10), Some(ChannelState::CsHangup));
552        assert_eq!(ChannelState::from_number(13), Some(ChannelState::CsNone));
553        assert_eq!(ChannelState::from_number(14), None);
554        assert_eq!(ChannelState::from_number(255), None);
555    }
556
557    #[test]
558    fn test_channel_state_as_number() {
559        assert_eq!(ChannelState::CsNew.as_number(), 0);
560        assert_eq!(ChannelState::CsExecute.as_number(), 4);
561        assert_eq!(ChannelState::CsHangup.as_number(), 10);
562        assert_eq!(ChannelState::CsNone.as_number(), 13);
563    }
564
565    #[test]
566    fn channel_state_ordering_follows_lifecycle() {
567        assert!(ChannelState::CsNew < ChannelState::CsInit);
568        assert!(ChannelState::CsInit < ChannelState::CsRouting);
569        assert!(ChannelState::CsRouting < ChannelState::CsExecute);
570        assert!(ChannelState::CsExecute < ChannelState::CsHangup);
571        assert!(ChannelState::CsHangup < ChannelState::CsReporting);
572        assert!(ChannelState::CsReporting < ChannelState::CsDestroy);
573    }
574
575    // Negated >= reads as "not yet in teardown", which is the real consumer intent.
576    #[allow(clippy::nonminimal_bool)]
577    #[test]
578    fn channel_state_teardown_check() {
579        assert!(ChannelState::CsHangup >= ChannelState::CsHangup);
580        assert!(ChannelState::CsReporting >= ChannelState::CsHangup);
581        assert!(ChannelState::CsDestroy >= ChannelState::CsHangup);
582        assert!(!(ChannelState::CsExecute >= ChannelState::CsHangup));
583        assert!(!(ChannelState::CsPark >= ChannelState::CsHangup));
584    }
585
586    #[test]
587    fn only_destroy_is_terminal() {
588        assert!(ChannelState::CsDestroy.is_terminal());
589        for state in ChannelState::ALL {
590            if *state == ChannelState::CsDestroy {
591                continue;
592            }
593            assert!(!state.is_terminal(), "{state} is not an end of life");
594        }
595    }
596
597    // CS_NONE sorts above CS_DESTROY, so the `>= CsHangup` idiom above sweeps it
598    // in; the switch aborts if its state machine ever reaches it, so it ends
599    // nothing.
600    #[test]
601    fn cs_none_outranks_destroy_without_being_terminal() {
602        assert!(ChannelState::CsNone > ChannelState::CsDestroy);
603        assert!(ChannelState::CsNone >= ChannelState::CsHangup);
604        assert!(!ChannelState::CsNone.is_terminal());
605    }
606
607    // --- CallState tests (display / from_str / wrong-case / unknown are
608    //     generated by the `tests: call_state_wire_tests` clause on the
609    //     wire_enum! invocation above; only the ordering check stays
610    //     manual because it depends on enum-lexical order, not wire
611    //     format). ---
612
613    #[test]
614    fn call_state_ordering_matches_c_enum() {
615        assert!(CallState::Down < CallState::Dialing);
616        assert!(CallState::Dialing < CallState::Ringing);
617        assert!(CallState::Early < CallState::Active);
618        assert!(CallState::Active < CallState::Hangup);
619    }
620
621    // --- AnswerState / CallDirection wire tests are generated by
622    //     `tests: answer_state_wire_tests` / `tests: call_direction_wire_tests`
623    //     clauses on their wire_enum! invocations above. ---
624
625    // --- HangupCause tests ---
626
627    #[test]
628    fn hangup_cause_as_number_q850() {
629        assert_eq!(HangupCause::None.as_number(), 0);
630        assert_eq!(HangupCause::UnallocatedNumber.as_number(), 1);
631        assert_eq!(HangupCause::NormalClearing.as_number(), 16);
632        assert_eq!(HangupCause::UserBusy.as_number(), 17);
633        assert_eq!(HangupCause::NoAnswer.as_number(), 19);
634        assert_eq!(HangupCause::CallRejected.as_number(), 21);
635        assert_eq!(HangupCause::NormalUnspecified.as_number(), 31);
636        assert_eq!(HangupCause::Interworking.as_number(), 127);
637    }
638
639    #[test]
640    fn hangup_cause_as_number_freeswitch_extensions() {
641        assert_eq!(HangupCause::Success.as_number(), 142);
642        assert_eq!(HangupCause::OriginatorCancel.as_number(), 487);
643        assert_eq!(HangupCause::Crash.as_number(), 700);
644        assert_eq!(HangupCause::BlindTransfer.as_number(), 800);
645        assert_eq!(HangupCause::InvalidIdentity.as_number(), 823);
646    }
647
648    #[test]
649    fn hangup_cause_from_number_round_trip() {
650        let codes: &[u16] = &[0, 1, 16, 17, 19, 21, 31, 127, 142, 487, 700, 800, 823];
651        for &code in codes {
652            let cause = HangupCause::from_number(code).unwrap();
653            assert_eq!(cause.as_number(), code);
654        }
655    }
656
657    #[test]
658    fn hangup_cause_from_number_unknown() {
659        assert!(HangupCause::from_number(999).is_none());
660        assert!(HangupCause::from_number(4).is_none());
661    }
662
663    // --- from_sip_response tests (mapping from sofia_glue_sip_cause_to_freeswitch) ---
664
665    #[test]
666    fn from_sip_response_success() {
667        assert_eq!(
668            HangupCause::from_sip_response(200),
669            Some(HangupCause::NormalClearing)
670        );
671    }
672
673    #[test]
674    fn from_sip_response_4xx_auth_rejection() {
675        for code in [401, 402, 403, 407] {
676            assert_eq!(
677                HangupCause::from_sip_response(code),
678                Some(HangupCause::CallRejected),
679                "SIP {code}"
680            );
681        }
682    }
683
684    #[test]
685    fn from_sip_response_4xx_routing() {
686        assert_eq!(
687            HangupCause::from_sip_response(404),
688            Some(HangupCause::UnallocatedNumber)
689        );
690        assert_eq!(
691            HangupCause::from_sip_response(485),
692            Some(HangupCause::NoRouteDestination)
693        );
694        assert_eq!(
695            HangupCause::from_sip_response(484),
696            Some(HangupCause::InvalidNumberFormat)
697        );
698        assert_eq!(
699            HangupCause::from_sip_response(410),
700            Some(HangupCause::NumberChanged)
701        );
702    }
703
704    #[test]
705    fn from_sip_response_4xx_service() {
706        assert_eq!(
707            HangupCause::from_sip_response(405),
708            Some(HangupCause::ServiceUnavailable)
709        );
710        for code in [406, 415, 501] {
711            assert_eq!(
712                HangupCause::from_sip_response(code),
713                Some(HangupCause::ServiceNotImplemented),
714                "SIP {code}"
715            );
716        }
717    }
718
719    #[test]
720    fn from_sip_response_4xx_interworking() {
721        for code in [413, 414, 416, 420, 421, 423, 505, 513] {
722            assert_eq!(
723                HangupCause::from_sip_response(code),
724                Some(HangupCause::Interworking),
725                "SIP {code}"
726            );
727        }
728    }
729
730    #[test]
731    fn from_sip_response_4xx_timeout_and_busy() {
732        assert_eq!(
733            HangupCause::from_sip_response(408),
734            Some(HangupCause::RecoveryOnTimerExpire)
735        );
736        assert_eq!(
737            HangupCause::from_sip_response(504),
738            Some(HangupCause::RecoveryOnTimerExpire)
739        );
740        assert_eq!(
741            HangupCause::from_sip_response(480),
742            Some(HangupCause::NoUserResponse)
743        );
744        assert_eq!(
745            HangupCause::from_sip_response(486),
746            Some(HangupCause::UserBusy)
747        );
748        assert_eq!(
749            HangupCause::from_sip_response(487),
750            Some(HangupCause::OriginatorCancel)
751        );
752    }
753
754    #[test]
755    fn from_sip_response_4xx_temporary_failure() {
756        for code in [400, 481, 500, 503] {
757            assert_eq!(
758                HangupCause::from_sip_response(code),
759                Some(HangupCause::NormalTemporaryFailure),
760                "SIP {code}"
761            );
762        }
763    }
764
765    #[test]
766    fn from_sip_response_4xx_exchange_routing() {
767        for code in [482, 483] {
768            assert_eq!(
769                HangupCause::from_sip_response(code),
770                Some(HangupCause::ExchangeRoutingError),
771                "SIP {code}"
772            );
773        }
774    }
775
776    #[test]
777    fn from_sip_response_4xx_media() {
778        assert_eq!(
779            HangupCause::from_sip_response(488),
780            Some(HangupCause::IncompatibleDestination)
781        );
782        assert_eq!(
783            HangupCause::from_sip_response(606),
784            Some(HangupCause::IncompatibleDestination)
785        );
786    }
787
788    #[test]
789    fn from_sip_response_5xx() {
790        assert_eq!(
791            HangupCause::from_sip_response(502),
792            Some(HangupCause::NetworkOutOfOrder)
793        );
794    }
795
796    #[test]
797    fn from_sip_response_6xx() {
798        assert_eq!(
799            HangupCause::from_sip_response(600),
800            Some(HangupCause::UserBusy)
801        );
802        assert_eq!(
803            HangupCause::from_sip_response(603),
804            Some(HangupCause::CallRejected)
805        );
806        assert_eq!(
807            HangupCause::from_sip_response(604),
808            Some(HangupCause::NoRouteDestination)
809        );
810        assert_eq!(
811            HangupCause::from_sip_response(607),
812            Some(HangupCause::Unwanted)
813        );
814        assert_eq!(
815            HangupCause::from_sip_response(608),
816            Some(HangupCause::CallRejected)
817        );
818    }
819
820    #[test]
821    fn from_sip_response_stir_shaken() {
822        assert_eq!(
823            HangupCause::from_sip_response(428),
824            Some(HangupCause::NoIdentity)
825        );
826        assert_eq!(
827            HangupCause::from_sip_response(429),
828            Some(HangupCause::BadIdentityInfo)
829        );
830        assert_eq!(
831            HangupCause::from_sip_response(437),
832            Some(HangupCause::UnsupportedCertificate)
833        );
834        assert_eq!(
835            HangupCause::from_sip_response(438),
836            Some(HangupCause::InvalidIdentity)
837        );
838    }
839
840    #[test]
841    fn from_sip_response_unmapped_returns_none() {
842        // Provisional/success ranges without explicit mapping
843        for code in [100, 180, 183, 301, 302] {
844            assert_eq!(
845                HangupCause::from_sip_response(code),
846                None,
847                "SIP {code} should be None"
848            );
849        }
850        // Unmapped 4xx/5xx
851        for code in [409, 411, 412, 422, 489, 491, 493, 506, 580] {
852            assert_eq!(
853                HangupCause::from_sip_response(code),
854                None,
855                "SIP {code} should be None"
856            );
857        }
858    }
859
860    #[test]
861    fn timetable_field_names_one_header() {
862        assert_eq!(
863            TimetableField::ProfileCreated.as_str(),
864            "Profile-Created-Time"
865        );
866        assert_eq!(
867            TimetableField::Created.header_name(TimetablePrefix::Caller),
868            "Caller-Channel-Created-Time"
869        );
870        // Raw prefixes too, for the dynamic `Call-<n>` form from_lookup accepts.
871        assert_eq!(
872            TimetableField::Hungup.header_name("Call-1"),
873            "Call-1-Channel-Hangup-Time"
874        );
875    }
876
877    #[test]
878    fn timetable_fields_and_suffixes_come_from_one_table() {
879        let fields = [
880            TimetableField::ProfileCreated,
881            TimetableField::Created,
882            TimetableField::Answered,
883            TimetableField::Progress,
884            TimetableField::ProgressMedia,
885            TimetableField::Hungup,
886            TimetableField::Transferred,
887            TimetableField::Resurrected,
888            TimetableField::Bridged,
889            TimetableField::LastHold,
890            TimetableField::HoldAccum,
891        ];
892        assert_eq!(fields.len(), ChannelTimetable::SUFFIXES.len());
893        for (field, suffix) in fields
894            .iter()
895            .zip(ChannelTimetable::SUFFIXES)
896        {
897            assert_eq!(field.as_str(), *suffix);
898        }
899    }
900
901    #[cfg(feature = "esl")]
902    mod from_events {
903        use super::*;
904        use crate::event::EslEvent;
905        use crate::lookup::HeaderLookup;
906
907        #[test]
908        fn caller_timetable_all_fields() {
909            let mut event = EslEvent::new();
910            event.set_header("Caller-Profile-Created-Time", "1700000000000000");
911            event.set_header("Caller-Channel-Created-Time", "1700000001000000");
912            event.set_header("Caller-Channel-Answered-Time", "1700000005000000");
913            event.set_header("Caller-Channel-Progress-Time", "1700000002000000");
914            event.set_header("Caller-Channel-Progress-Media-Time", "1700000003000000");
915            event.set_header("Caller-Channel-Hangup-Time", "0");
916            event.set_header("Caller-Channel-Transfer-Time", "0");
917            event.set_header("Caller-Channel-Resurrect-Time", "0");
918            event.set_header("Caller-Channel-Bridged-Time", "1700000006000000");
919            event.set_header("Caller-Channel-Last-Hold", "0");
920            event.set_header("Caller-Channel-Hold-Accum", "0");
921
922            let tt = event
923                .caller_timetable()
924                .unwrap()
925                .expect("should have timetable");
926            assert_eq!(tt.profile_created, Some(1700000000000000));
927            assert_eq!(tt.created, Some(1700000001000000));
928            assert_eq!(tt.answered, Some(1700000005000000));
929            assert_eq!(tt.progress, Some(1700000002000000));
930            assert_eq!(tt.progress_media, Some(1700000003000000));
931            assert_eq!(tt.hungup, Some(0));
932            assert_eq!(tt.transferred, Some(0));
933            assert_eq!(tt.resurrected, Some(0));
934            assert_eq!(tt.bridged, Some(1700000006000000));
935            assert_eq!(tt.last_hold, Some(0));
936            assert_eq!(tt.hold_accum, Some(0));
937        }
938
939        #[test]
940        fn other_leg_timetable() {
941            let mut event = EslEvent::new();
942            event.set_header("Other-Leg-Profile-Created-Time", "1700000000000000");
943            event.set_header("Other-Leg-Channel-Created-Time", "1700000001000000");
944            event.set_header("Other-Leg-Channel-Answered-Time", "1700000005000000");
945            event.set_header("Other-Leg-Channel-Progress-Time", "0");
946            event.set_header("Other-Leg-Channel-Progress-Media-Time", "0");
947            event.set_header("Other-Leg-Channel-Hangup-Time", "0");
948            event.set_header("Other-Leg-Channel-Transfer-Time", "0");
949            event.set_header("Other-Leg-Channel-Resurrect-Time", "0");
950            event.set_header("Other-Leg-Channel-Bridged-Time", "1700000006000000");
951            event.set_header("Other-Leg-Channel-Last-Hold", "0");
952            event.set_header("Other-Leg-Channel-Hold-Accum", "0");
953
954            let tt = event
955                .other_leg_timetable()
956                .unwrap()
957                .expect("should have timetable");
958            assert_eq!(tt.created, Some(1700000001000000));
959            assert_eq!(tt.bridged, Some(1700000006000000));
960        }
961
962        #[test]
963        fn timetable_no_headers() {
964            let event = EslEvent::new();
965            assert_eq!(
966                event
967                    .caller_timetable()
968                    .unwrap(),
969                None
970            );
971            assert_eq!(
972                event
973                    .other_leg_timetable()
974                    .unwrap(),
975                None
976            );
977        }
978
979        #[test]
980        fn timetable_partial_headers() {
981            let mut event = EslEvent::new();
982            event.set_header("Caller-Channel-Created-Time", "1700000001000000");
983
984            let tt = event
985                .caller_timetable()
986                .unwrap()
987                .expect("at least one field parsed");
988            assert_eq!(tt.created, Some(1700000001000000));
989            assert_eq!(tt.answered, None);
990            assert_eq!(tt.profile_created, None);
991        }
992
993        #[test]
994        fn timetable_invalid_value_is_error() {
995            let mut event = EslEvent::new();
996            event.set_header("Caller-Channel-Created-Time", "not_a_number");
997
998            let err = event
999                .caller_timetable()
1000                .unwrap_err();
1001            assert_eq!(err.header, "Caller-Channel-Created-Time");
1002            assert_eq!(err.value, "not_a_number");
1003        }
1004
1005        #[test]
1006        fn timetable_valid_then_invalid_is_error() {
1007            let mut event = EslEvent::new();
1008            event.set_header("Caller-Profile-Created-Time", "1700000000000000");
1009            event.set_header("Caller-Channel-Created-Time", "garbage");
1010
1011            let err = event
1012                .caller_timetable()
1013                .unwrap_err();
1014            assert_eq!(err.header, "Caller-Channel-Created-Time");
1015            assert_eq!(err.value, "garbage");
1016        }
1017
1018        #[test]
1019        fn timetable_zero_preserved() {
1020            let mut event = EslEvent::new();
1021            event.set_header("Caller-Channel-Hangup-Time", "0");
1022
1023            let tt = event
1024                .caller_timetable()
1025                .unwrap()
1026                .expect("should have timetable");
1027            assert_eq!(tt.hungup, Some(0));
1028        }
1029
1030        #[test]
1031        fn timetable_field_header_name_is_what_from_lookup_reads() {
1032            let mut event = EslEvent::new();
1033            event.set_header(
1034                TimetableField::Bridged.header_name(TimetablePrefix::OtherLeg),
1035                "1700000006000000",
1036            );
1037
1038            let tt = event
1039                .other_leg_timetable()
1040                .unwrap()
1041                .expect("should have timetable");
1042            assert_eq!(tt.bridged, Some(1700000006000000));
1043        }
1044
1045        #[test]
1046        fn timetable_custom_prefix() {
1047            let mut event = EslEvent::new();
1048            event.set_header("Channel-Channel-Created-Time", "1700000001000000");
1049
1050            let tt = event
1051                .timetable("Channel")
1052                .unwrap()
1053                .expect("custom prefix should work");
1054            assert_eq!(tt.created, Some(1700000001000000));
1055        }
1056    }
1057}