1use alloc::{borrow::Cow, string::String};
13
14#[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub struct VcardTimestamp<'a>(pub Cow<'a, str>);
39
40impl VcardTimestamp<'_> {
41 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
72fn 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
90fn 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
106fn 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 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 if hour > 23 || minute > 59 || second > 60 {
131 return None;
132 }
133 Some((hour, minute, second, offset))
134}
135
136fn 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
162fn 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 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 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 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}