Skip to main content

voip_ms/
types.rs

1//! Hand-written domain types used in place of `String` in selected
2//! generated request and response fields.
3//!
4//! Types here are wired into `src/generated.rs` by `xtask` through the
5//! field-name override table in `xtask/src/field_overrides.rs`.
6
7use rust_decimal::Decimal;
8use serde::de::{Deserializer, Error as DeError, Visitor};
9use serde::ser::Serializer;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::str::FromStr;
13
14/// A VoIP.ms routing target encoded on the wire as `tag:payload`.
15///
16/// VoIP.ms uses this `tag:payload` scheme across all routing-like fields
17/// (`routing`, `failover_busy`, `routing_match`, the `fail_over_routing_*`
18/// family, …). Documented tags are mapped to named variants; anything else
19/// is preserved verbatim in [`Routing::Unknown`] so that VoIP.ms adding a
20/// new tag does not break deserialization or round-tripping.
21///
22/// `none:` (no routing) is represented by [`Routing::None`].
23///
24/// # Wire format
25///
26/// * `account:100001_VoIP` → [`Routing::Account`]
27/// * `fwd:15555` → [`Routing::Forward`]
28/// * `vm:101` → [`Routing::Voicemail`]
29/// * `sip:user@host` → [`Routing::Sip`]
30/// * `sys:5` → [`Routing::System`]
31/// * `grp:42` → [`Routing::Group`]
32/// * `queue:7` → [`Routing::Queue`]
33/// * `ivr:3` → [`Routing::Ivr`]
34/// * `cb:2359` → [`Routing::Callback`]
35/// * `tc:11` → [`Routing::TimeCondition`]
36/// * `disa:1` → [`Routing::Disa`]
37/// * `did:5551234567` → [`Routing::Did`]
38/// * `phone:5551234567` → [`Routing::Phone`]
39/// * `none:` → [`Routing::None`]
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub enum Routing {
42    /// No routing (wire: `none:`).
43    None,
44    /// Sub-account by name (wire: `account:NAME`).
45    Account(String),
46    /// Forwarding entry by id (wire: `fwd:ID`).
47    Forward(String),
48    /// Voicemail box (wire: `vm:MAILBOX`).
49    Voicemail(String),
50    /// External SIP URI (wire: `sip:user@host`).
51    Sip(String),
52    /// System recording / system action (wire: `sys:ID`).
53    System(String),
54    /// Ring group by id (wire: `grp:ID`).
55    Group(String),
56    /// Queue by id (wire: `queue:ID`).
57    Queue(String),
58    /// IVR menu by id (wire: `ivr:ID`).
59    Ivr(String),
60    /// Callback entry by id (wire: `cb:ID`).
61    Callback(String),
62    /// Time condition by id (wire: `tc:ID`).
63    TimeCondition(String),
64    /// DISA entry by id (wire: `disa:ID`).
65    Disa(String),
66    /// DID number (wire: `did:NUMBER`).
67    Did(String),
68    /// Outbound phone number (wire: `phone:NUMBER`).
69    Phone(String),
70    /// Any tag this crate doesn't recognize. The original wire form is
71    /// preserved as `tag:value` so it round-trips unchanged.
72    Unknown { tag: String, value: String },
73}
74
75impl Routing {
76    /// The wire tag (the substring before the `:`).
77    pub fn tag(&self) -> &str {
78        match self {
79            Routing::None => "none",
80            Routing::Account(_) => "account",
81            Routing::Forward(_) => "fwd",
82            Routing::Voicemail(_) => "vm",
83            Routing::Sip(_) => "sip",
84            Routing::System(_) => "sys",
85            Routing::Group(_) => "grp",
86            Routing::Queue(_) => "queue",
87            Routing::Ivr(_) => "ivr",
88            Routing::Callback(_) => "cb",
89            Routing::TimeCondition(_) => "tc",
90            Routing::Disa(_) => "disa",
91            Routing::Did(_) => "did",
92            Routing::Phone(_) => "phone",
93            Routing::Unknown { tag, .. } => tag,
94        }
95    }
96
97    /// The wire payload (the substring after the `:`).
98    pub fn value(&self) -> &str {
99        match self {
100            Routing::None => "",
101            Routing::Account(v)
102            | Routing::Forward(v)
103            | Routing::Voicemail(v)
104            | Routing::Sip(v)
105            | Routing::System(v)
106            | Routing::Group(v)
107            | Routing::Queue(v)
108            | Routing::Ivr(v)
109            | Routing::Callback(v)
110            | Routing::TimeCondition(v)
111            | Routing::Disa(v)
112            | Routing::Did(v)
113            | Routing::Phone(v) => v,
114            Routing::Unknown { value, .. } => value,
115        }
116    }
117}
118
119impl fmt::Display for Routing {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{}:{}", self.tag(), self.value())
122    }
123}
124
125/// Parse a `tag:value` string into a [`Routing`].
126///
127/// An empty string is rejected; use [`Option::None`] in the surrounding
128/// struct to represent an absent value.
129impl FromStr for Routing {
130    type Err = RoutingParseError;
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        let (tag, value) = match s.find(':') {
134            Some(i) => (&s[..i], &s[i + 1..]),
135            None => return Err(RoutingParseError::MissingColon),
136        };
137
138        Ok(match tag {
139            "none" => Routing::None,
140            "account" => Routing::Account(value.into()),
141            "fwd" => Routing::Forward(value.into()),
142            "vm" => Routing::Voicemail(value.into()),
143            "sip" => Routing::Sip(value.into()),
144            "sys" => Routing::System(value.into()),
145            "grp" => Routing::Group(value.into()),
146            "queue" => Routing::Queue(value.into()),
147            "ivr" => Routing::Ivr(value.into()),
148            "cb" => Routing::Callback(value.into()),
149            "tc" => Routing::TimeCondition(value.into()),
150            "disa" => Routing::Disa(value.into()),
151            "did" => Routing::Did(value.into()),
152            "phone" => Routing::Phone(value.into()),
153            other => Routing::Unknown {
154                tag: other.to_string(),
155                value: value.to_string(),
156            },
157        })
158    }
159}
160
161/// Error from parsing a [`Routing`] from a string.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum RoutingParseError {
164    /// The input contained no `:` separator.
165    MissingColon,
166}
167
168impl fmt::Display for RoutingParseError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            RoutingParseError::MissingColon => {
172                f.write_str("routing string is missing required `:` separator")
173            }
174        }
175    }
176}
177
178impl std::error::Error for RoutingParseError {}
179
180impl Serialize for Routing {
181    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
182    where
183        S: Serializer,
184    {
185        serializer.collect_str(self)
186    }
187}
188
189impl<'de> Deserialize<'de> for Routing {
190    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191    where
192        D: Deserializer<'de>,
193    {
194        struct RoutingVisitor;
195
196        impl<'de> Visitor<'de> for RoutingVisitor {
197            type Value = Routing;
198
199            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200                f.write_str("a VoIP.ms routing string of the form `tag:value`")
201            }
202
203            fn visit_str<E>(self, v: &str) -> Result<Routing, E>
204            where
205                E: DeError,
206            {
207                Routing::from_str(v).map_err(E::custom)
208            }
209
210            fn visit_string<E>(self, v: String) -> Result<Routing, E>
211            where
212                E: DeError,
213            {
214                Routing::from_str(&v).map_err(E::custom)
215            }
216        }
217
218        deserializer.deserialize_str(RoutingVisitor)
219    }
220}
221
222/// A duration in seconds, or an "unbounded" sentinel.
223///
224/// Several VoIP.ms queue/announcement fields take a number of seconds *or* a
225/// word meaning no limit (`none` / `unlimited`), so a bare `u64` can't hold the
226/// sentinel. [`Seconds`] serializes the sentinel as `none`; [`WaitTime`] as
227/// `unlimited` (the word `maximum_wait_time` documents). Both deserialize
228/// tolerantly: a number, a numeric string, or either sentinel word.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
230pub enum Seconds {
231    /// A concrete number of seconds.
232    Value(u64),
233    /// No limit (wire: `none`).
234    Unlimited,
235}
236
237/// A wait time in seconds, or unlimited.
238///
239/// Like [`Seconds`] but serializes the unbounded case as `unlimited`, the word
240/// `maximum_wait_time` documents.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242pub enum WaitTime {
243    /// A concrete number of seconds.
244    Value(u64),
245    /// No limit (wire: `unlimited`).
246    Unlimited,
247}
248
249/// A conference member cap, or unlimited.
250///
251/// `getConference` reports `max_members` as a count or the word `Unlimited`
252/// when the conference has no cap.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254pub enum MaxMembers {
255    /// A concrete member cap.
256    Value(u64),
257    /// No cap (wire: `Unlimited`).
258    Unlimited,
259}
260
261macro_rules! impl_seconds {
262    ($name:ident, $unlimited_wire:literal, $expecting:literal) => {
263        impl From<u64> for $name {
264            fn from(v: u64) -> Self {
265                $name::Value(v)
266            }
267        }
268
269        impl Serialize for $name {
270            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
271            where
272                S: Serializer,
273            {
274                match self {
275                    $name::Value(v) => serializer.serialize_str(&v.to_string()),
276                    $name::Unlimited => serializer.serialize_str($unlimited_wire),
277                }
278            }
279        }
280
281        impl<'de> Deserialize<'de> for $name {
282            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283            where
284                D: Deserializer<'de>,
285            {
286                struct SecondsVisitor;
287
288                impl<'de> Visitor<'de> for SecondsVisitor {
289                    type Value = $name;
290
291                    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292                        f.write_str($expecting)
293                    }
294
295                    fn visit_u64<E>(self, v: u64) -> Result<$name, E>
296                    where
297                        E: DeError,
298                    {
299                        Ok($name::Value(v))
300                    }
301
302                    fn visit_str<E>(self, v: &str) -> Result<$name, E>
303                    where
304                        E: DeError,
305                    {
306                        let t = v.trim();
307                        match t.to_ascii_lowercase().as_str() {
308                            "none" | "unlimited" => Ok($name::Unlimited),
309                            _ => t
310                                .parse::<u64>()
311                                .map($name::Value)
312                                .map_err(|_| E::custom(format!("invalid seconds value {v}"))),
313                        }
314                    }
315                }
316
317                deserializer.deserialize_any(SecondsVisitor)
318            }
319        }
320    };
321}
322
323impl_seconds!(Seconds, "none", "a number of seconds or `none`");
324impl_seconds!(WaitTime, "unlimited", "a number of seconds or `unlimited`");
325impl_seconds!(MaxMembers, "Unlimited", "a member count or `Unlimited`");
326
327/// A UTC offset in hours, the wire form of the record-listing `timezone` knob.
328///
329/// The `getCDR`, `getResellerCDR`, `getSMS`, `getMMS`, `getResellerSMS`, and
330/// `getResellerMMS` methods take a `timezone` parameter that VoIP.ms documents
331/// as "adjust the times of the records according to Timezone (Numeric: -12 to
332/// 13)". It is a whole- or fractional-hour offset from UTC, not an IANA zone
333/// name -- distinct from the `getTimezones` / voicemail `timezone`, which is a
334/// named zone (`America/New_York`). Passing `-5` returns timestamps in
335/// UTC-05:00; omitting it leaves them in the account's configured timezone.
336///
337/// Callers hold a [`chrono_tz::Tz`] on those methods' `timezone` field; the
338/// crate resolves it to this offset at the query's start date via
339/// [`TimezoneOffset::at`] before sending. Construct one directly only to bypass
340/// zone resolution and pin a fixed numeric offset.
341///
342/// Wraps a [`Decimal`] constrained to `-12..=13`. [`TimezoneOffset::new`]
343/// rejects out-of-range values so a nonsensical offset never reaches the wire.
344///
345/// # Wire format
346///
347/// Serializes as a bare number (`-5`, `5.5`); deserializes tolerantly from a
348/// JSON number or a numeric string.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
350pub struct TimezoneOffset(Decimal);
351
352impl TimezoneOffset {
353    /// The inclusive range VoIP.ms accepts, in hours from UTC.
354    const MIN: i64 = -12;
355    const MAX: i64 = 13;
356
357    /// Construct an offset, rejecting a value outside `-12..=13` hours.
358    pub fn new(hours: impl Into<Decimal>) -> Result<Self, TimezoneOffsetError> {
359        let hours = hours.into();
360        if hours < Decimal::from(Self::MIN) || hours > Decimal::from(Self::MAX) {
361            return Err(TimezoneOffsetError::OutOfRange(hours));
362        }
363
364        Ok(Self(hours))
365    }
366
367    /// The UTC offset of `tz` on `date`, the value VoIP.ms wants for a query
368    /// starting that day.
369    ///
370    /// VoIP.ms takes a single numeric offset for a whole date range, but a
371    /// zone's offset shifts across DST boundaries, so the offset is fixed at
372    /// one instant -- local noon on `date`, chosen to sit clear of the
373    /// midnight DST fold. A zone whose offset exceeds `-12..=13` (e.g.
374    /// `Pacific/Kiritimati`, +14) has no VoIP.ms representation and returns
375    /// [`TimezoneOffsetError::OutOfRange`].
376    pub fn at(tz: chrono_tz::Tz, date: chrono::NaiveDate) -> Result<Self, TimezoneOffsetError> {
377        use chrono::{Offset, TimeZone};
378
379        let noon = date
380            .and_hms_opt(12, 0, 0)
381            .expect("12:00:00 is a valid time");
382        let seconds = tz
383            .from_local_datetime(&noon)
384            .earliest()
385            .or_else(|| tz.from_local_datetime(&noon).latest())
386            .map(|dt| dt.offset().fix().local_minus_utc())
387            .ok_or(TimezoneOffsetError::UnresolvableInstant)?;
388        // Whole hours where the offset is on the hour (the common case);
389        // fractional zones (e.g. India +5:30) keep the remainder.
390        let hours = Decimal::from(seconds) / Decimal::from(3600);
391        Self::new(hours)
392    }
393
394    /// The offset in hours from UTC.
395    pub fn hours(&self) -> Decimal {
396        self.0
397    }
398}
399
400impl fmt::Display for TimezoneOffset {
401    /// Renders the offset as a signed `UTC±HH:MM` label (`UTC-05:00`).
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        let sign = if self.0.is_sign_negative() { '-' } else { '+' };
404        let abs = self.0.abs();
405        let whole = abs.trunc();
406        let minutes = ((abs - whole) * Decimal::from(60)).round();
407        write!(f, "UTC{sign}{whole:02}:{minutes:02}")
408    }
409}
410
411impl FromStr for TimezoneOffset {
412    type Err = TimezoneOffsetError;
413
414    fn from_str(s: &str) -> Result<Self, Self::Err> {
415        let hours =
416            Decimal::from_str_exact(s.trim()).map_err(|_| TimezoneOffsetError::NotNumeric)?;
417        Self::new(hours)
418    }
419}
420
421impl TryFrom<i64> for TimezoneOffset {
422    type Error = TimezoneOffsetError;
423
424    fn try_from(hours: i64) -> Result<Self, Self::Error> {
425        Self::new(Decimal::from(hours))
426    }
427}
428
429/// Error from constructing a [`TimezoneOffset`].
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub enum TimezoneOffsetError {
432    /// The value fell outside the `-12..=13` hour range VoIP.ms accepts. Also
433    /// returned by [`TimezoneOffset::at`] for a zone whose offset exceeds that
434    /// range (e.g. `Pacific/Kiritimati`, +14).
435    OutOfRange(Decimal),
436    /// The string was not a number.
437    NotNumeric,
438    /// The chosen instant does not exist in the zone (a DST spring-forward
439    /// gap), so no offset could be resolved.
440    UnresolvableInstant,
441    /// A `timezone` zone was given without the query start date that anchors
442    /// its DST resolution (`date_from` / `from`), so there is no instant to
443    /// resolve the offset at.
444    MissingStartDate,
445    /// The query start date string did not parse as a `YYYY-MM-DD` date, so
446    /// the zone's offset could not be resolved at it.
447    InvalidStartDate,
448}
449
450impl fmt::Display for TimezoneOffsetError {
451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452        match self {
453            TimezoneOffsetError::OutOfRange(v) => {
454                write!(f, "timezone offset {v} is outside the range -12 to 13")
455            }
456
457            TimezoneOffsetError::NotNumeric => f.write_str("timezone offset is not a number"),
458            TimezoneOffsetError::UnresolvableInstant => {
459                f.write_str("timezone offset could not be resolved at the given date")
460            }
461
462            TimezoneOffsetError::MissingStartDate => f.write_str(
463                "timezone requires the query start date (date_from / from) to resolve its offset",
464            ),
465            TimezoneOffsetError::InvalidStartDate => {
466                f.write_str("query start date is not a YYYY-MM-DD date")
467            }
468        }
469    }
470}
471
472impl std::error::Error for TimezoneOffsetError {}
473
474impl Serialize for TimezoneOffset {
475    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
476    where
477        S: Serializer,
478    {
479        serializer.collect_str(&self.0)
480    }
481}
482
483impl<'de> Deserialize<'de> for TimezoneOffset {
484    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
485    where
486        D: Deserializer<'de>,
487    {
488        struct OffsetVisitor;
489
490        impl<'de> Visitor<'de> for OffsetVisitor {
491            type Value = TimezoneOffset;
492
493            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494                f.write_str("a UTC-offset number in hours between -12 and 13")
495            }
496
497            fn visit_i64<E>(self, v: i64) -> Result<TimezoneOffset, E>
498            where
499                E: DeError,
500            {
501                TimezoneOffset::try_from(v).map_err(E::custom)
502            }
503
504            fn visit_u64<E>(self, v: u64) -> Result<TimezoneOffset, E>
505            where
506                E: DeError,
507            {
508                let v = i64::try_from(v).map_err(|_| E::custom("timezone offset out of range"))?;
509                TimezoneOffset::try_from(v).map_err(E::custom)
510            }
511
512            fn visit_f64<E>(self, v: f64) -> Result<TimezoneOffset, E>
513            where
514                E: DeError,
515            {
516                let d = Decimal::try_from(v)
517                    .map_err(|_| E::custom("timezone offset is not a valid number"))?;
518                TimezoneOffset::new(d).map_err(E::custom)
519            }
520
521            fn visit_str<E>(self, v: &str) -> Result<TimezoneOffset, E>
522            where
523                E: DeError,
524            {
525                TimezoneOffset::from_str(v).map_err(E::custom)
526            }
527        }
528
529        deserializer.deserialize_any(OffsetVisitor)
530    }
531}
532
533/// A named time zone as VoIP.ms reports it: a parsed [`chrono_tz::Tz`] when
534/// the bundled IANA database recognizes the name, or the verbatim wire string
535/// when it does not.
536///
537/// VoIP.ms's `getTimezones` reference catalog still lists a handful of legacy
538/// names the IANA database has since dropped (`Asia/Beijing`,
539/// `US/Pacific-New`, `Factory`, ...), and a long-lived mailbox may carry one;
540/// preserving them beats failing the whole response. Parsing never fails --
541/// an unrecognized name lands in [`TimezoneName::Unrecognized`] and
542/// round-trips unchanged.
543#[derive(Debug, Clone, PartialEq, Eq, Hash)]
544pub enum TimezoneName {
545    /// A zone the bundled IANA database recognizes.
546    Known(chrono_tz::Tz),
547    /// A name it does not recognize, preserved verbatim.
548    Unrecognized(String),
549}
550
551impl TimezoneName {
552    /// The recognized zone, or `None` for a legacy name.
553    pub fn tz(&self) -> Option<chrono_tz::Tz> {
554        match self {
555            TimezoneName::Known(tz) => Some(*tz),
556            TimezoneName::Unrecognized(_) => None,
557        }
558    }
559
560    /// The zone name as VoIP.ms spells it.
561    pub fn name(&self) -> &str {
562        match self {
563            TimezoneName::Known(tz) => tz.name(),
564            TimezoneName::Unrecognized(s) => s,
565        }
566    }
567}
568
569impl fmt::Display for TimezoneName {
570    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
571        f.write_str(self.name())
572    }
573}
574
575impl FromStr for TimezoneName {
576    type Err = std::convert::Infallible;
577
578    fn from_str(s: &str) -> Result<Self, Self::Err> {
579        Ok(s.parse::<chrono_tz::Tz>()
580            .map(TimezoneName::Known)
581            .unwrap_or_else(|_| TimezoneName::Unrecognized(s.to_string())))
582    }
583}
584
585impl From<chrono_tz::Tz> for TimezoneName {
586    fn from(tz: chrono_tz::Tz) -> Self {
587        TimezoneName::Known(tz)
588    }
589}
590
591impl Serialize for TimezoneName {
592    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
593    where
594        S: Serializer,
595    {
596        serializer.serialize_str(self.name())
597    }
598}
599
600impl<'de> Deserialize<'de> for TimezoneName {
601    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
602    where
603        D: Deserializer<'de>,
604    {
605        struct NameVisitor;
606
607        impl<'de> Visitor<'de> for NameVisitor {
608            type Value = TimezoneName;
609
610            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
611                f.write_str("an IANA time zone name")
612            }
613
614            fn visit_str<E>(self, v: &str) -> Result<TimezoneName, E>
615            where
616                E: DeError,
617            {
618                let Ok(name) = TimezoneName::from_str(v);
619                Ok(name)
620            }
621        }
622
623        deserializer.deserialize_str(NameVisitor)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn parses_documented_tags() {
633        assert_eq!(Routing::from_str("none:").unwrap(), Routing::None);
634        assert_eq!(
635            Routing::from_str("account:100001_VoIP").unwrap(),
636            Routing::Account("100001_VoIP".into()),
637        );
638        assert_eq!(
639            Routing::from_str("fwd:15555").unwrap(),
640            Routing::Forward("15555".into()),
641        );
642        assert_eq!(
643            Routing::from_str("vm:101").unwrap(),
644            Routing::Voicemail("101".into()),
645        );
646        assert_eq!(
647            Routing::from_str("cb:2359").unwrap(),
648            Routing::Callback("2359".into()),
649        );
650    }
651
652    #[test]
653    fn preserves_unknown_tags() {
654        let r = Routing::from_str("future:abc").unwrap();
655        assert_eq!(
656            r,
657            Routing::Unknown {
658                tag: "future".into(),
659                value: "abc".into(),
660            },
661        );
662        assert_eq!(r.to_string(), "future:abc");
663    }
664
665    #[test]
666    fn sip_value_can_contain_colons() {
667        // Split is on the FIRST colon so sip URIs survive intact.
668        let r = Routing::from_str("sip:5552223333@sip.voip.ms:5060").unwrap();
669        assert_eq!(r, Routing::Sip("5552223333@sip.voip.ms:5060".into()));
670        assert_eq!(r.to_string(), "sip:5552223333@sip.voip.ms:5060");
671    }
672
673    #[test]
674    fn rejects_missing_colon() {
675        assert_eq!(
676            Routing::from_str("nocolon"),
677            Err(RoutingParseError::MissingColon),
678        );
679    }
680
681    #[test]
682    fn round_trips_through_serde() {
683        let r = Routing::Forward("19998887777".into());
684        let json = serde_json::to_string(&r).unwrap();
685        assert_eq!(json, "\"fwd:19998887777\"");
686        let back: Routing = serde_json::from_str(&json).unwrap();
687        assert_eq!(back, r);
688    }
689
690    #[test]
691    fn deserialize_none() {
692        let r: Routing = serde_json::from_str("\"none:\"").unwrap();
693        assert_eq!(r, Routing::None);
694    }
695
696    #[test]
697    fn seconds_serializes_value_and_sentinel() {
698        assert_eq!(
699            serde_json::to_string(&Seconds::Value(30)).unwrap(),
700            "\"30\""
701        );
702        assert_eq!(
703            serde_json::to_string(&Seconds::Unlimited).unwrap(),
704            "\"none\""
705        );
706        assert_eq!(
707            serde_json::to_string(&WaitTime::Unlimited).unwrap(),
708            "\"unlimited\""
709        );
710    }
711
712    #[test]
713    fn seconds_deserializes_number_string_and_sentinels() {
714        // A bare number, a numeric string, and either sentinel word all parse.
715        assert_eq!(
716            serde_json::from_str::<Seconds>("45").unwrap(),
717            Seconds::Value(45)
718        );
719        assert_eq!(
720            serde_json::from_str::<Seconds>("\"45\"").unwrap(),
721            Seconds::Value(45)
722        );
723        for s in ["\"none\"", "\"NONE\"", "\"unlimited\""] {
724            assert_eq!(
725                serde_json::from_str::<Seconds>(s).unwrap(),
726                Seconds::Unlimited,
727                "{s}"
728            );
729        }
730        // WaitTime shares the tolerant parse.
731        assert_eq!(
732            serde_json::from_str::<WaitTime>("\"unlimited\"").unwrap(),
733            WaitTime::Unlimited
734        );
735    }
736
737    #[test]
738    fn max_members_handles_count_and_capital_unlimited() {
739        // The wire sends a numeric string or the capitalized word `Unlimited`.
740        assert_eq!(
741            serde_json::from_str::<MaxMembers>("\"40\"").unwrap(),
742            MaxMembers::Value(40)
743        );
744        assert_eq!(
745            serde_json::from_str::<MaxMembers>("\"Unlimited\"").unwrap(),
746            MaxMembers::Unlimited
747        );
748        // Serialize round-trips the exact wire form, capital `U` included.
749        assert_eq!(
750            serde_json::to_string(&MaxMembers::Unlimited).unwrap(),
751            "\"Unlimited\""
752        );
753        assert_eq!(
754            serde_json::to_string(&MaxMembers::Value(40)).unwrap(),
755            "\"40\""
756        );
757    }
758
759    #[test]
760    fn timezone_offset_rejects_out_of_range() {
761        assert!(TimezoneOffset::try_from(-12).is_ok());
762        assert!(TimezoneOffset::try_from(13).is_ok());
763        assert_eq!(
764            TimezoneOffset::try_from(14),
765            Err(TimezoneOffsetError::OutOfRange(Decimal::from(14)))
766        );
767        assert_eq!(
768            TimezoneOffset::try_from(-13),
769            Err(TimezoneOffsetError::OutOfRange(Decimal::from(-13)))
770        );
771    }
772
773    #[test]
774    fn timezone_offset_deserializes_number_and_string() {
775        // Number, integer string, and fractional string all parse.
776        assert_eq!(
777            serde_json::from_str::<TimezoneOffset>("-5").unwrap(),
778            TimezoneOffset::try_from(-5).unwrap()
779        );
780        assert_eq!(
781            serde_json::from_str::<TimezoneOffset>("\"-5\"").unwrap(),
782            TimezoneOffset::try_from(-5).unwrap()
783        );
784        assert_eq!(
785            serde_json::from_str::<TimezoneOffset>("\"5.5\"").unwrap(),
786            TimezoneOffset::new(Decimal::from_str_exact("5.5").unwrap()).unwrap()
787        );
788        // Out-of-range fails at deserialize time.
789        assert!(serde_json::from_str::<TimezoneOffset>("99").is_err());
790    }
791
792    #[test]
793    fn timezone_offset_serializes_as_bare_number() {
794        assert_eq!(
795            serde_json::to_string(&TimezoneOffset::try_from(-5).unwrap()).unwrap(),
796            "\"-5\""
797        );
798    }
799
800    #[test]
801    fn timezone_offset_displays_utc_label() {
802        assert_eq!(
803            TimezoneOffset::try_from(-5).unwrap().to_string(),
804            "UTC-05:00"
805        );
806        assert_eq!(
807            TimezoneOffset::try_from(13).unwrap().to_string(),
808            "UTC+13:00"
809        );
810        assert_eq!(
811            TimezoneOffset::new(Decimal::from_str_exact("5.5").unwrap())
812                .unwrap()
813                .to_string(),
814            "UTC+05:30"
815        );
816    }
817
818    #[test]
819    fn timezone_offset_at_resolves_dst() {
820        use chrono::NaiveDate;
821        let jan = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
822        let jul = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap();
823        // America/New_York: EST (-5) in winter, EDT (-4) in summer.
824        assert_eq!(
825            TimezoneOffset::at(chrono_tz::America::New_York, jan).unwrap(),
826            TimezoneOffset::try_from(-5).unwrap()
827        );
828        assert_eq!(
829            TimezoneOffset::at(chrono_tz::America::New_York, jul).unwrap(),
830            TimezoneOffset::try_from(-4).unwrap()
831        );
832        // Arizona does not observe DST: -7 year-round.
833        assert_eq!(
834            TimezoneOffset::at(chrono_tz::America::Phoenix, jul).unwrap(),
835            TimezoneOffset::try_from(-7).unwrap()
836        );
837        // UTC is 0.
838        assert_eq!(
839            TimezoneOffset::at(chrono_tz::UTC, jan).unwrap(),
840            TimezoneOffset::try_from(0).unwrap()
841        );
842    }
843
844    #[test]
845    fn timezone_name_parses_known_and_preserves_legacy() {
846        let known: TimezoneName = "America/New_York".parse().unwrap();
847        assert_eq!(known, TimezoneName::Known(chrono_tz::America::New_York));
848        assert_eq!(known.tz(), Some(chrono_tz::America::New_York));
849        assert_eq!(known.name(), "America/New_York");
850
851        // voip.ms's catalog still lists zone names the IANA database dropped;
852        // they must survive verbatim instead of failing the parse.
853        let legacy: TimezoneName = "Asia/Beijing".parse().unwrap();
854        assert_eq!(legacy, TimezoneName::Unrecognized("Asia/Beijing".into()));
855        assert_eq!(legacy.tz(), None);
856        assert_eq!(legacy.name(), "Asia/Beijing");
857        assert_eq!(legacy.to_string(), "Asia/Beijing");
858    }
859
860    #[test]
861    fn timezone_name_round_trips_through_serde() {
862        for name in ["America/New_York", "US/Pacific-New"] {
863            let parsed: TimezoneName = name.parse().unwrap();
864            let json = serde_json::to_string(&parsed).unwrap();
865            assert_eq!(json, format!("\"{name}\""));
866            let back: TimezoneName = serde_json::from_str(&json).unwrap();
867            assert_eq!(back, parsed);
868        }
869    }
870
871    #[test]
872    fn timezone_offset_at_handles_sub_hour_and_out_of_range() {
873        use chrono::NaiveDate;
874        let day = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
875        // India is UTC+5:30 -- a fractional offset survives.
876        assert_eq!(
877            TimezoneOffset::at(chrono_tz::Asia::Kolkata, day).unwrap(),
878            TimezoneOffset::new(Decimal::from_str_exact("5.5").unwrap()).unwrap()
879        );
880        // Kiritimati is UTC+14, outside voip.ms's -12..=13 range.
881        assert_eq!(
882            TimezoneOffset::at(chrono_tz::Pacific::Kiritimati, day),
883            Err(TimezoneOffsetError::OutOfRange(Decimal::from(14)))
884        );
885    }
886}