1use time::format_description::well_known::Rfc3339;
18use time::{OffsetDateTime, UtcOffset};
19
20pub fn render(at: OffsetDateTime) -> String {
32 at.to_offset(UtcOffset::UTC)
33 .format(&Rfc3339)
34 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum DatetimeError {
40 #[error("datetime is malformed: {0}")]
43 Malformed(&'static str),
44 #[error("datetime has no timezone designator")]
46 MissingTimezone,
47 #[error("datetime uses `-00:00`, which is not a valid ISO 8601 offset")]
50 NegativeZeroOffset,
51 #[error("datetime field {field} is out of range: {value}")]
54 OutOfRange {
55 field: &'static str,
57 value: u32,
59 },
60 #[error("datetime names {year:04}-{month:02}-{day:02}, which is not a real date")]
62 NoSuchDay {
63 year: u32,
65 month: u32,
67 day: u32,
69 },
70 #[error("datetime normalizes to before 0000-01-01T00:00:00Z")]
75 BeforeEpoch,
76}
77
78pub fn validate(value: &str) -> Result<(), DatetimeError> {
89 let bytes = value.as_bytes();
90 if bytes.len() < 20 {
92 return Err(DatetimeError::Malformed("too short to be a datetime"));
93 }
94 if !value.is_ascii() {
95 return Err(DatetimeError::Malformed("non-ascii character"));
97 }
98
99 let digits = |from: usize, len: usize| -> Option<u32> {
100 let slice = bytes.get(from..from + len)?;
101 if !slice.iter().all(u8::is_ascii_digit) {
102 return None;
103 }
104 std::str::from_utf8(slice).ok()?.parse().ok()
105 };
106 let literal = |at: usize, ch: u8| bytes.get(at) == Some(&ch);
107
108 let year = digits(0, 4).ok_or(DatetimeError::Malformed("year is not four digits"))?;
109 if !literal(4, b'-') {
110 return Err(DatetimeError::Malformed("expected `-` after the year"));
111 }
112 let month = digits(5, 2).ok_or(DatetimeError::Malformed("month is not two digits"))?;
113 if !literal(7, b'-') {
114 return Err(DatetimeError::Malformed("expected `-` after the month"));
115 }
116 let day = digits(8, 2).ok_or(DatetimeError::Malformed("day is not two digits"))?;
117 if !literal(10, b'T') {
118 return Err(DatetimeError::Malformed(
119 "expected an uppercase `T` separator",
120 ));
121 }
122 let hour = digits(11, 2).ok_or(DatetimeError::Malformed("hour is not two digits"))?;
123 if !literal(13, b':') {
124 return Err(DatetimeError::Malformed("expected `:` after the hour"));
125 }
126 let minute = digits(14, 2).ok_or(DatetimeError::Malformed("minute is not two digits"))?;
127 if !literal(16, b':') {
128 return Err(DatetimeError::Malformed("expected `:` after the minute"));
129 }
130 let second = digits(17, 2).ok_or(DatetimeError::Malformed("second is not two digits"))?;
131
132 let mut cursor = 19;
133 if literal(cursor, b'.') {
134 cursor += 1;
135 let start = cursor;
136 while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
137 cursor += 1;
138 }
139 if cursor == start {
140 return Err(DatetimeError::Malformed("fractional seconds are empty"));
142 }
143 }
144
145 let offset_minutes = match bytes.get(cursor) {
146 None => return Err(DatetimeError::MissingTimezone),
147 Some(b'Z') => {
148 if cursor + 1 != bytes.len() {
149 return Err(DatetimeError::Malformed("trailing characters after `Z`"));
150 }
151 0i64
152 }
153 Some(sign @ (b'+' | b'-')) => {
154 let negative = *sign == b'-';
155 if cursor + 6 != bytes.len() {
156 return Err(DatetimeError::Malformed("offset is not `±HH:MM`"));
157 }
158 let hours = digits(cursor + 1, 2)
159 .ok_or(DatetimeError::Malformed("offset hour is not two digits"))?;
160 if !literal(cursor + 3, b':') {
161 return Err(DatetimeError::Malformed("expected `:` in the offset"));
162 }
163 let minutes = digits(cursor + 4, 2)
164 .ok_or(DatetimeError::Malformed("offset minute is not two digits"))?;
165 if hours > 23 {
166 return Err(DatetimeError::OutOfRange {
167 field: "offset hour",
168 value: hours,
169 });
170 }
171 if minutes > 59 {
172 return Err(DatetimeError::OutOfRange {
173 field: "offset minute",
174 value: minutes,
175 });
176 }
177 let total = i64::from(hours) * 60 + i64::from(minutes);
178 if negative && total == 0 {
179 return Err(DatetimeError::NegativeZeroOffset);
180 }
181 if negative {
182 -total
183 } else {
184 total
185 }
186 }
187 Some(_) => return Err(DatetimeError::MissingTimezone),
189 };
190
191 range("month", month, 1, 12)?;
192 range("day", day, 1, 31)?;
193 range("hour", hour, 0, 23)?;
194 range("minute", minute, 0, 59)?;
195 range("second", second, 0, 60)?;
197 if day > days_in_month(year, month) {
198 return Err(DatetimeError::NoSuchDay { year, month, day });
199 }
200
201 if year == 0 && offset_minutes > 0 {
205 let into_year = i64::from(days_before_month(year, month) + day - 1) * 1440
206 + i64::from(hour) * 60
207 + i64::from(minute);
208 if into_year - offset_minutes < 0 {
209 return Err(DatetimeError::BeforeEpoch);
210 }
211 }
212
213 Ok(())
214}
215
216fn range(field: &'static str, value: u32, low: u32, high: u32) -> Result<(), DatetimeError> {
218 if value < low || value > high {
219 return Err(DatetimeError::OutOfRange { field, value });
220 }
221 Ok(())
222}
223
224fn is_leap(year: u32) -> bool {
226 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
227}
228
229fn days_in_month(year: u32, month: u32) -> u32 {
231 match month {
232 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
233 4 | 6 | 9 | 11 => 30,
234 2 if is_leap(year) => 29,
235 2 => 28,
236 _ => 0,
238 }
239}
240
241fn days_before_month(year: u32, month: u32) -> u32 {
243 (1..month).map(|m| days_in_month(year, m)).sum()
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn the_preferred_form_is_accepted() {
252 assert!(validate("1985-04-12T23:20:50.123Z").is_ok());
253 }
254
255 #[test]
256 fn leap_days_follow_the_gregorian_rule() {
257 assert!(
258 validate("2000-02-29T00:00:00Z").is_ok(),
259 "2000 is a leap year"
260 );
261 assert!(validate("1900-02-29T00:00:00Z").is_err(), "1900 is not");
262 assert!(validate("2024-02-29T00:00:00Z").is_ok());
263 assert!(validate("2023-02-29T00:00:00Z").is_err());
264 }
265
266 #[test]
267 fn a_leap_second_is_a_real_value_and_sixty_one_is_not() {
268 assert!(validate("2016-12-31T23:59:60Z").is_ok());
269 assert_eq!(
270 validate("2016-12-31T23:59:61Z"),
271 Err(DatetimeError::OutOfRange {
272 field: "second",
273 value: 61
274 })
275 );
276 }
277
278 #[test]
279 fn offset_unknown_is_refused_but_plus_zero_is_not() {
280 assert_eq!(
281 validate("1985-04-12T23:20:50.123-00:00"),
282 Err(DatetimeError::NegativeZeroOffset)
283 );
284 assert!(validate("1985-04-12T23:20:50.123+00:00").is_ok());
285 }
286
287 #[test]
290 fn a_rendered_instant_is_a_valid_datetime() {
291 for at in [
292 time::macros::datetime!(2026-09-22 21:00:00 +02:00),
293 time::macros::datetime!(2026-09-22 19:00:00.5 UTC),
294 time::macros::datetime!(0000-01-01 00:00:00 -01:00),
295 ] {
296 let text = render(at);
297 assert!(text.ends_with('Z'), "{text} is not in UTC");
298 assert_eq!(validate(&text), Ok(()), "{text}");
299 }
300 assert_eq!(
301 render(time::macros::datetime!(0000-01-01 00:00:00 +01:00)),
302 "1970-01-01T00:00:00Z",
303 "an instant before year zero has no RFC 3339 form"
304 );
305 }
306
307 #[test]
308 fn year_zero_cannot_be_pushed_before_the_start_of_time() {
309 assert_eq!(
310 validate("0000-01-01T00:00:00+01:00"),
311 Err(DatetimeError::BeforeEpoch)
312 );
313 assert!(validate("0000-01-01T00:00:00-01:00").is_ok());
314 assert!(validate("0000-01-02T00:00:00+01:00").is_ok());
315 }
316}