Skip to main content

vcard/value/
datetime.rs

1//! # Date and time values
2//!
3//! The decoded time-related value kinds: a date-and-or-time, and a timestamp.
4//!
5//! [`VcardDateAndOrTime`] (RFC 6350 4.3.4) backs `BDAY` and `ANNIVERSARY`;
6//! [`VcardTimestamp`] (RFC 6350 4.3.5) backs `REV`. Their reduced-precision
7//! grammar is intricate (omitted components, truncated forms), so the value is
8//! kept as its raw text rather than decoded into broken calendar fields at the
9//! risk of a lossy round-trip. Callers needing calendar semantics parse the
10//! string themselves.
11
12use alloc::{borrow::Cow, string::String};
13
14/// A decoded date-and-or-time value, kept as its raw text.
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
16pub struct VcardDateAndOrTime<'a>(pub Cow<'a, str>);
17
18impl<'a> From<&'a str> for VcardDateAndOrTime<'a> {
19    fn from(value: &'a str) -> Self {
20        Self(Cow::Borrowed(value))
21    }
22}
23
24impl From<String> for VcardDateAndOrTime<'_> {
25    fn from(value: String) -> Self {
26        Self(Cow::Owned(value))
27    }
28}
29
30impl<'a> From<Cow<'a, str>> for VcardDateAndOrTime<'a> {
31    fn from(value: Cow<'a, str>) -> Self {
32        Self(value)
33    }
34}
35
36/// A decoded timestamp value, kept as its raw text.
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub struct VcardTimestamp<'a>(pub Cow<'a, str>);
39
40impl VcardTimestamp<'_> {
41    /// Normalizes the RFC 6350 timestamp to seconds from the Unix epoch, so two
42    /// revisions can be ordered; `None` when the text will not parse.
43    ///
44    /// Accepts the ISO 8601 basic (`20260711T172559Z`) and extended
45    /// (`2026-07-11T17:25:59Z`) forms, a `Z` or numeric offset, and reduced
46    /// precision (omitted trailing components read as zero); no zone means UTC.
47    /// For ordering only: it disagrees with the derived `PartialEq`, which
48    /// compares raw text, so it is not exposed as `Ord`.
49    pub fn to_unix_seconds(&self) -> Option<i64> {
50        parse_timestamp(self.0.as_ref())
51    }
52}
53
54impl<'a> From<&'a str> for VcardTimestamp<'a> {
55    fn from(value: &'a str) -> Self {
56        Self(Cow::Borrowed(value))
57    }
58}
59
60impl From<String> for VcardTimestamp<'_> {
61    fn from(value: String) -> Self {
62        Self(Cow::Owned(value))
63    }
64}
65
66impl<'a> From<Cow<'a, str>> for VcardTimestamp<'a> {
67    fn from(value: Cow<'a, str>) -> Self {
68        Self(value)
69    }
70}
71
72/// Parses an RFC 6350 timestamp into seconds from the Unix epoch, UTC.
73fn parse_timestamp(raw: &str) -> Option<i64> {
74    let text = raw.trim();
75    let (date, time) = match text.find(['T', 't']) {
76        Some(pos) => (&text[..pos], Some(&text[pos + 1..])),
77        None => (text, None),
78    };
79
80    let (year, month, day) = parse_date(date)?;
81    let (hour, minute, second, offset) = match time {
82        Some(time) => parse_time(time)?,
83        None => (0, 0, 0, 0),
84    };
85
86    let days = days_from_civil(year, month, day);
87    Some(days * 86_400 + hour * 3_600 + minute * 60 + second - offset)
88}
89
90/// Parses the date part (basic `YYYYMMDD` or extended `YYYY-MM-DD`).
91fn parse_date(date: &str) -> Option<(i64, i64, i64)> {
92    let digits: String = date.chars().filter(|c| *c != '-').collect();
93    if digits.len() != 8 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
94        return None;
95    }
96
97    let year = digits[0..4].parse().ok()?;
98    let month = digits[4..6].parse().ok()?;
99    let day = digits[6..8].parse().ok()?;
100    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
101        return None;
102    }
103    Some((year, month, day))
104}
105
106/// Parses the time part with its optional zone, returning the hour,
107/// minute, second and the zone offset in seconds.
108fn parse_time(time: &str) -> Option<(i64, i64, i64, i64)> {
109    let (clock, offset) = if let Some(stripped) = time.strip_suffix(['Z', 'z']) {
110        (stripped, 0)
111    } else if let Some(sign) = time.rfind(['+', '-']) {
112        (&time[..sign], parse_offset(&time[sign..])?)
113    } else {
114        (time, 0)
115    };
116
117    // NOTE: Trailing time components may be omitted; pad them to zero.
118    let mut digits: String = clock.chars().filter(|c| *c != ':').collect();
119    if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
120        return None;
121    }
122    while digits.len() < 6 {
123        digits.push('0');
124    }
125
126    let hour = digits[0..2].parse().ok()?;
127    let minute = digits[2..4].parse().ok()?;
128    let second = digits[4..6].parse().ok()?;
129    // NOTE: A leap second (60) is accepted and folds into the next minute.
130    if hour > 23 || minute > 59 || second > 60 {
131        return None;
132    }
133    Some((hour, minute, second, offset))
134}
135
136/// Parses a signed zone offset (`+02`, `+0200`, `-05:00`) into seconds.
137fn parse_offset(zone: &str) -> Option<i64> {
138    let sign = match zone.as_bytes().first()? {
139        b'+' => 1,
140        b'-' => -1,
141        _ => return None,
142    };
143
144    let digits: String = zone[1..].chars().filter(|c| *c != ':').collect();
145    if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
146        return None;
147    }
148    let (hours, minutes) = match digits.len() {
149        2 => (digits[0..2].parse::<i64>().ok()?, 0),
150        4 => (
151            digits[0..2].parse::<i64>().ok()?,
152            digits[2..4].parse::<i64>().ok()?,
153        ),
154        _ => return None,
155    };
156    if hours > 23 || minutes > 59 {
157        return None;
158    }
159    Some(sign * (hours * 3_600 + minutes * 60))
160}
161
162/// Days from the Unix epoch to the civil date (Howard Hinnant's
163/// algorithm), integer-only so it stays `no_std`.
164fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
165    let year = if month <= 2 { year - 1 } else { year };
166    let era = (if year >= 0 { year } else { year - 399 }) / 400;
167    let year_of_era = year - era * 400;
168    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
169    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
170    era * 146_097 + day_of_era - 719_468
171}
172
173#[cfg(test)]
174mod tests {
175    use crate::value::datetime::VcardTimestamp;
176
177    fn seconds(raw: &str) -> Option<i64> {
178        VcardTimestamp::from(raw).to_unix_seconds()
179    }
180
181    #[test]
182    fn parses_basic_and_extended_utc_alike() {
183        assert_eq!(seconds("20260711T172559Z"), seconds("2026-07-11T17:25:59Z"));
184    }
185
186    #[test]
187    fn zone_offset_folds_into_utc() {
188        // NOTE: 19:25:59+02:00 is the same instant as 17:25:59Z.
189        assert_eq!(
190            seconds("2026-07-11T19:25:59+02:00"),
191            seconds("2026-07-11T17:25:59Z"),
192        );
193        assert_eq!(
194            seconds("2026-07-11T12:25:59-0500"),
195            seconds("2026-07-11T17:25:59Z"),
196        );
197        // NOTE: The hour-only form, which the offset grammar allows and nothing
198        // exercised before.
199        assert_eq!(
200            seconds("2026-07-11T19:25:59+02"),
201            seconds("2026-07-11T17:25:59Z"),
202        );
203    }
204
205    #[test]
206    fn a_later_revision_orders_after_an_earlier_one() {
207        assert!(seconds("2026-07-11T17:25:59Z") > seconds("2026-07-11T17:08:14Z"));
208        assert!(seconds("2027-01-01T00:00:00Z") > seconds("2026-12-31T23:59:59Z"));
209    }
210
211    #[test]
212    fn a_missing_zone_reads_as_utc() {
213        assert_eq!(
214            seconds("2026-07-11T17:25:59"),
215            seconds("2026-07-11T17:25:59Z")
216        );
217    }
218
219    #[test]
220    fn reduced_precision_pads_with_zero() {
221        assert_eq!(seconds("2026-07-11T17Z"), seconds("2026-07-11T170000Z"));
222        assert_eq!(seconds("20260711"), seconds("2026-07-11T000000Z"));
223    }
224
225    #[test]
226    fn rejects_non_timestamps() {
227        assert_eq!(seconds(""), None);
228        assert_eq!(seconds("not-a-date"), None);
229        assert_eq!(seconds("2026-13-11T00:00:00Z"), None);
230        assert_eq!(seconds("2026-07-11T25:00:00Z"), None);
231        // An offset with no sign, with non-digits, of an unusable width, or out
232        // of range is not an offset.
233        assert_eq!(seconds("2026-07-11T19:25:5902:00"), None);
234        assert_eq!(seconds("2026-07-11T19:25:59+ab:00"), None);
235        assert_eq!(seconds("2026-07-11T19:25:59+020"), None);
236        assert_eq!(seconds("2026-07-11T19:25:59+24:00"), None);
237        assert_eq!(seconds("2026-07-11T19:25:59+02:60"), None);
238    }
239}