1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Timestamp {
23 year: i64,
24 month: u32,
25 day: u32,
26 hour: u32,
27 minute: u32,
28 second: u32,
29 offset_minutes: i32,
32}
33
34pub const EPOCH_RFC3339: &str = "1970-01-01T00:00:00Z";
41
42pub fn parse(raw: &str) -> Option<Timestamp> {
52 let s = raw.trim();
53 let bytes = s.as_bytes();
54 if bytes.len() < 10 {
55 return None;
56 }
57
58 let year: i64 = digits(&s[0..4])? as i64;
59 if bytes[4] != b'-' {
60 return None;
61 }
62 let month = digits(&s[5..7])?;
63 if bytes[7] != b'-' {
64 return None;
65 }
66 let day = digits(&s[8..10])?;
67 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
68 return None;
69 }
70
71 let mut ts = Timestamp {
72 year,
73 month,
74 day,
75 hour: 0,
76 minute: 0,
77 second: 0,
78 offset_minutes: 0,
79 };
80
81 let rest = &s[10..];
82 if rest.is_empty() {
83 return Some(ts);
84 }
85
86 let sep = rest.as_bytes()[0];
88 if !matches!(sep, b'T' | b't' | b' ') {
89 return None;
90 }
91 let rest = rest[1..].trim_start();
92 if rest.len() < 5 {
93 return None;
94 }
95 ts.hour = digits(&rest[0..2])?;
96 if rest.as_bytes()[2] != b':' {
97 return None;
98 }
99 ts.minute = digits(&rest[3..5])?;
100 let mut rest = &rest[5..];
101 if rest.starts_with(':') {
102 if rest.len() < 3 {
103 return None;
104 }
105 ts.second = digits(&rest[1..3])?;
106 rest = &rest[3..];
107 }
108 if rest.starts_with('.') {
110 let end = rest[1..]
111 .find(|c: char| !c.is_ascii_digit())
112 .map_or(rest.len(), |i| i + 1);
113 rest = &rest[end..];
114 }
115 if ts.hour > 23 || ts.minute > 59 || ts.second > 60 {
116 return None;
117 }
118
119 ts.offset_minutes = parse_offset(rest.trim())?;
120 Some(ts)
121}
122
123fn parse_offset(s: &str) -> Option<i32> {
125 if s.is_empty() {
126 return Some(0);
127 }
128 if s.eq_ignore_ascii_case("z") {
129 return Some(0);
130 }
131 let sign = match s.as_bytes()[0] {
132 b'+' => 1,
133 b'-' => -1,
134 _ => return None,
135 };
136 let body = &s[1..];
137 let (hh, mm) = match body.len() {
138 5 if body.as_bytes()[2] == b':' => (&body[0..2], &body[3..5]),
139 4 => (&body[0..2], &body[2..4]),
140 2 => (&body[0..2], "00"),
141 _ => return None,
142 };
143 let hours = digits(hh)? as i32;
144 let minutes = digits(mm)? as i32;
145 if hours > 23 || minutes > 59 {
146 return None;
147 }
148 Some(sign * (hours * 60 + minutes))
149}
150
151fn digits(s: &str) -> Option<u32> {
154 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
155 return None;
156 }
157 s.parse().ok()
158}
159
160fn is_leap(year: i64) -> bool {
161 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
162}
163
164fn days_in_month(year: i64, month: u32) -> u32 {
165 match month {
166 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
167 4 | 6 | 9 | 11 => 30,
168 2 if is_leap(year) => 29,
169 2 => 28,
170 _ => 0,
171 }
172}
173
174const MONTHS: [&str; 12] = [
175 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
176];
177const WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
178
179impl Timestamp {
180 fn weekday(&self) -> usize {
185 const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
186 let mut y = self.year;
187 if self.month < 3 {
188 y -= 1;
189 }
190 let idx =
191 y + y / 4 - y / 100 + y / 400 + OFFSETS[(self.month - 1) as usize] + self.day as i64;
192 idx.rem_euclid(7) as usize
193 }
194
195 pub fn to_rfc3339(&self) -> String {
197 let zone = if self.offset_minutes == 0 {
198 "Z".to_string()
199 } else {
200 let (sign, abs) = signed(self.offset_minutes);
201 format!("{sign}{:02}:{:02}", abs / 60, abs % 60)
202 };
203 format!(
204 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{zone}",
205 self.year, self.month, self.day, self.hour, self.minute, self.second
206 )
207 }
208
209 pub fn to_rfc822(&self) -> String {
212 let (sign, abs) = signed(self.offset_minutes);
213 format!(
214 "{}, {:02} {} {:04} {:02}:{:02}:{:02} {sign}{:02}{:02}",
215 WEEKDAYS[self.weekday()],
216 self.day,
217 MONTHS[(self.month - 1) as usize],
218 self.year,
219 self.hour,
220 self.minute,
221 self.second,
222 abs / 60,
223 abs % 60,
224 )
225 }
226}
227
228fn signed(offset_minutes: i32) -> (char, i32) {
229 if offset_minutes < 0 {
230 ('-', -offset_minutes)
231 } else {
232 ('+', offset_minutes)
233 }
234}
235
236pub fn to_rfc3339(raw: &str) -> Option<String> {
238 parse(raw).map(|ts| ts.to_rfc3339())
239}
240
241pub fn to_rfc822(raw: &str) -> Option<String> {
243 parse(raw).map(|ts| ts.to_rfc822())
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
253 fn a_bare_day_becomes_midnight_utc() {
254 assert_eq!(to_rfc3339("2026-08-16").unwrap(), "2026-08-16T00:00:00Z");
255 assert_eq!(
256 to_rfc822("2026-08-16").unwrap(),
257 "Sun, 16 Aug 2026 00:00:00 +0000"
258 );
259 }
260
261 #[test]
262 fn a_full_rfc3339_instant_survives_the_round_trip() {
263 assert_eq!(
264 to_rfc3339("2026-08-16T09:30:15Z").unwrap(),
265 "2026-08-16T09:30:15Z"
266 );
267 assert_eq!(
268 to_rfc822("2026-08-16T09:30:15Z").unwrap(),
269 "Sun, 16 Aug 2026 09:30:15 +0000"
270 );
271 }
272
273 #[test]
276 fn a_yaml_timestamp_is_read_too() {
277 assert_eq!(
278 to_rfc3339("2026-08-16 09:30:15").unwrap(),
279 "2026-08-16T09:30:15Z"
280 );
281 }
282
283 #[test]
284 fn an_offset_is_kept_in_both_grammars() {
285 assert_eq!(
286 to_rfc3339("2026-08-16T09:30:00+02:00").unwrap(),
287 "2026-08-16T09:30:00+02:00"
288 );
289 assert_eq!(
290 to_rfc822("2026-08-16T09:30:00+02:00").unwrap(),
291 "Sun, 16 Aug 2026 09:30:00 +0200"
292 );
293 assert_eq!(
294 to_rfc822("2026-08-16T09:30:00-0530").unwrap(),
295 "Sun, 16 Aug 2026 09:30:00 -0530"
296 );
297 }
298
299 #[test]
300 fn seconds_and_fractions_are_optional() {
301 assert_eq!(
302 to_rfc3339("2026-08-16T09:30Z").unwrap(),
303 "2026-08-16T09:30:00Z"
304 );
305 assert_eq!(
306 to_rfc3339("2026-08-16T09:30:15.250Z").unwrap(),
307 "2026-08-16T09:30:15Z"
308 );
309 }
310
311 #[test]
315 fn weekdays_are_derived_correctly() {
316 assert!(to_rfc822("2024-02-29").unwrap().starts_with("Thu,"));
317 assert!(to_rfc822("2000-01-01").unwrap().starts_with("Sat,"));
318 assert!(to_rfc822("1970-01-01").unwrap().starts_with("Thu,"));
319 assert!(to_rfc822("2026-01-15").unwrap().starts_with("Thu,"));
320 assert!(to_rfc822("2100-03-01").unwrap().starts_with("Mon,"));
321 }
322
323 #[test]
326 fn impossible_and_malformed_dates_are_refused() {
327 for bad in [
328 "",
329 "not a date",
330 "2026-13-01",
331 "2026-02-30",
332 "2023-02-29",
333 "2026-00-10",
334 "2026-08-00",
335 "2026-8-16",
336 "2026/08/16",
337 "20260816",
338 "2026-08-16X09:30:00Z",
339 "2026-08-16T25:00:00Z",
340 "2026-08-16T09:70:00Z",
341 "2026-08-16T09:30:00+99:00",
342 ] {
343 assert!(parse(bad).is_none(), "{bad:?} should not parse");
344 }
345 }
346
347 #[test]
349 fn leap_years_follow_the_gregorian_rule() {
350 assert!(parse("2024-02-29").is_some());
351 assert!(parse("2000-02-29").is_some());
352 assert!(parse("1900-02-29").is_none());
353 assert!(parse("2026-02-29").is_none());
354 }
355}