Skip to main content

ical/value/
utc_offset.rs

1//! # UTC-offset value
2//!
3//! The decoded UTC-offset value kind.
4//!
5//! Backs `TZOFFSETFROM` and `TZOFFSETTO`: a signed `+/-HHMM[SS]` offset from
6//! UTC (RFC 5545 3.3.14; e.g. `-0500`), kept as its raw text so it goes back
7//! on the wire exactly as it arrived.
8//!
9//! [`IcalUtcOffset::seconds`] reads it as a number for a caller that needs to
10//! apply it, and for [`crate::tz`], which resolves a civil time against the
11//! `VTIMEZONE` a calendar carries.
12
13use core::ops::Range;
14
15use alloc::{borrow::Cow, string::String};
16
17/// A decoded UTC-offset value (signed `hhmm`), kept as its raw text.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct IcalUtcOffset<'a>(pub Cow<'a, str>);
20
21impl IcalUtcOffset<'_> {
22    /// The offset in seconds east of UTC, so `-0500` reads as `-18000`.
23    ///
24    /// `None` for anything that is not the RFC 5545 3.3.14 `+/-hhmm[ss]`
25    /// form, parsing being liberal enough elsewhere to let one through.
26    pub fn seconds(&self) -> Option<i32> {
27        let text = self.0.as_ref();
28
29        let (sign, digits) = match text.as_bytes().first()? {
30            b'+' => (1, &text[1..]),
31            b'-' => (-1, &text[1..]),
32            _ => (1, text),
33        };
34
35        if !matches!(digits.len(), 4 | 6) || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
36            return None;
37        }
38
39        let part = |range: Range<usize>| digits[range].parse::<i32>().ok();
40
41        let hours = part(0..2)?;
42        let minutes = part(2..4)?;
43        let seconds = if digits.len() == 6 { part(4..6)? } else { 0 };
44
45        Some(sign * (hours * 3600 + minutes * 60 + seconds))
46    }
47}
48
49impl<'a> From<&'a str> for IcalUtcOffset<'a> {
50    fn from(value: &'a str) -> Self {
51        Self(Cow::Borrowed(value))
52    }
53}
54
55impl From<String> for IcalUtcOffset<'_> {
56    fn from(value: String) -> Self {
57        Self(Cow::Owned(value))
58    }
59}
60
61impl<'a> From<Cow<'a, str>> for IcalUtcOffset<'a> {
62    fn from(value: Cow<'a, str>) -> Self {
63        Self(value)
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use crate::value::utc_offset::IcalUtcOffset;
70
71    #[test]
72    fn reads_every_offset_spelling() {
73        assert_eq!(IcalUtcOffset::from("-0500").seconds(), Some(-18_000));
74        assert_eq!(IcalUtcOffset::from("+0100").seconds(), Some(3_600));
75        assert_eq!(IcalUtcOffset::from("+053045").seconds(), Some(19_845));
76        assert_eq!(IcalUtcOffset::from("0000").seconds(), Some(0));
77    }
78
79    #[test]
80    fn refuses_what_is_not_an_offset() {
81        assert_eq!(IcalUtcOffset::from("").seconds(), None);
82        assert_eq!(IcalUtcOffset::from("+5").seconds(), None);
83        assert_eq!(IcalUtcOffset::from("+05:00").seconds(), None);
84        assert_eq!(IcalUtcOffset::from("+0h00").seconds(), None);
85    }
86}