1use std::fmt;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct Date {
28 pub year: i32,
30 pub month: u32,
32 pub day: u32,
34}
35
36impl Date {
37 #[must_use]
40 pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
41 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
42 return None;
43 }
44 Some(Self { year, month, day })
45 }
46
47 #[must_use]
50 pub fn parse(s: &str) -> Option<Self> {
51 let b = s.as_bytes();
52 if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
53 return None;
54 }
55 if !b
56 .iter()
57 .enumerate()
58 .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
59 {
60 return None;
61 }
62 Self::new(
63 s[0..4].parse().ok()?,
64 s[5..7].parse().ok()?,
65 s[8..10].parse().ok()?,
66 )
67 }
68
69 #[must_use]
73 pub fn today_utc() -> Option<Self> {
74 let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
75 let secs = i64::try_from(secs).unwrap_or(i64::MAX);
78 Some(Self::from_days_since_epoch(secs.div_euclid(86_400)))
79 }
80
81 #[must_use]
83 pub fn days_since_epoch(&self) -> i64 {
84 days_from_civil(self.year, self.month, self.day)
85 }
86
87 #[must_use]
89 pub fn from_days_since_epoch(days: i64) -> Self {
90 let (year, month, day) = civil_from_days(days);
91 Self { year, month, day }
92 }
93
94 #[must_use]
96 pub const fn to_utc_datetime(&self) -> DateTime {
97 DateTime::from_date_utc(*self)
98 }
99}
100
101impl fmt::Display for Date {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
104 }
105}
106
107impl std::str::FromStr for Date {
108 type Err = ParseDateError;
109 fn from_str(s: &str) -> Result<Self, Self::Err> {
110 Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ParseDateError(pub String);
117
118impl fmt::Display for ParseDateError {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 write!(f, "not an ISO-8601 date/datetime: {:?}", self.0)
121 }
122}
123
124impl std::error::Error for ParseDateError {}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133pub struct DateTime {
134 pub date: Date,
136 pub hour: u32,
138 pub minute: u32,
140 pub second: u32,
142 pub nanosecond: u32,
144 pub offset_minutes: Option<i32>,
146 pub has_time: bool,
148}
149
150impl DateTime {
151 pub fn parse(s: &str) -> Option<Self> {
157 let s = s.trim();
158 if !s.is_ascii() || s.len() < 10 {
159 return None;
160 }
161 let date = Date::parse(&s[..10])?;
162 let rest = &s[10..];
163 if rest.is_empty() {
164 return Some(Self {
165 date,
166 hour: 0,
167 minute: 0,
168 second: 0,
169 nanosecond: 0,
170 offset_minutes: None,
171 has_time: false,
172 });
173 }
174
175 let sep = rest.as_bytes()[0];
176 if sep != b'T' && sep != b't' && sep != b' ' {
177 return None;
178 }
179 let mut rest = &rest[1..];
180
181 let hour = take_u32(&mut rest, 2)?;
182 expect(&mut rest, ':')?;
183 let minute = take_u32(&mut rest, 2)?;
184 let mut second = 0;
185 let mut nanosecond = 0;
186 if rest.starts_with(':') {
187 rest = &rest[1..];
188 second = take_u32(&mut rest, 2)?;
189 if rest.starts_with('.') || rest.starts_with(',') {
190 rest = &rest[1..];
191 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
192 if digits.is_empty() {
193 return None;
194 }
195 rest = &rest[digits.len()..];
196 let mut nanos = digits;
198 nanos.truncate(9);
199 while nanos.len() < 9 {
200 nanos.push('0');
201 }
202 nanosecond = nanos.parse().ok()?;
203 }
204 }
205 if hour > 23 || minute > 59 || second > 60 {
206 return None;
207 }
208
209 let offset_minutes = parse_offset(rest)?;
210 Some(Self {
211 date,
212 hour,
213 minute,
214 second,
215 nanosecond,
216 offset_minutes,
217 has_time: true,
218 })
219 }
220
221 #[must_use]
224 pub fn to_utc_seconds(&self) -> i64 {
225 self.date.days_since_epoch() * 86_400
226 + i64::from(self.hour) * 3600
227 + i64::from(self.minute) * 60
228 + i64::from(self.second)
229 - i64::from(self.offset_minutes.unwrap_or(0)) * 60
230 }
231
232 #[must_use]
235 pub fn utc_date(&self) -> Date {
236 Date::from_days_since_epoch(self.to_utc_seconds().div_euclid(86_400))
237 }
238
239 #[must_use]
241 pub const fn has_offset(&self) -> bool {
242 self.offset_minutes.is_some()
243 }
244
245 #[must_use]
247 pub fn now_utc() -> Option<Self> {
248 let duration = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
249 let secs = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
250 let nanos = duration.subsec_nanos();
251 let days = secs.div_euclid(86_400);
252 let rem_secs = secs.rem_euclid(86_400);
253 let hour = u32::try_from(rem_secs / 3600).ok()?;
254 let minute = u32::try_from((rem_secs % 3600) / 60).ok()?;
255 let second = u32::try_from(rem_secs % 60).ok()?;
256 Some(Self {
257 date: Date::from_days_since_epoch(days),
258 hour,
259 minute,
260 second,
261 nanosecond: nanos,
262 offset_minutes: Some(0),
263 has_time: true,
264 })
265 }
266
267 #[must_use]
269 pub const fn from_date_utc(date: Date) -> Self {
270 Self {
271 date,
272 hour: 0,
273 minute: 0,
274 second: 0,
275 nanosecond: 0,
276 offset_minutes: Some(0),
277 has_time: true,
278 }
279 }
280}
281
282impl PartialOrd for DateTime {
283 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
284 Some(self.cmp(other))
285 }
286}
287
288impl Ord for DateTime {
289 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
290 self.to_utc_seconds()
291 .cmp(&other.to_utc_seconds())
292 .then(self.nanosecond.cmp(&other.nanosecond))
293 }
294}
295
296impl fmt::Display for DateTime {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 write!(f, "{}", self.date)?;
299 if !self.has_time {
300 return Ok(());
301 }
302 write!(f, "T{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
303 if self.nanosecond > 0 {
304 let frac = format!("{:09}", self.nanosecond);
305 write!(f, ".{}", frac.trim_end_matches('0'))?;
306 }
307 match self.offset_minutes {
308 None => Ok(()),
309 Some(0) => f.write_str("Z"),
310 Some(m) => {
311 let sign = if m < 0 { '-' } else { '+' };
312 write!(f, "{sign}{:02}:{:02}", m.abs() / 60, m.abs() % 60)
313 }
314 }
315 }
316}
317
318impl std::str::FromStr for DateTime {
319 type Err = ParseDateError;
320 fn from_str(s: &str) -> Result<Self, Self::Err> {
321 Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
322 }
323}
324
325#[derive(Clone, Debug, PartialEq, Eq)]
332pub struct DateField {
333 pub raw: String,
335 pub date: Option<Date>,
337}
338
339impl DateField {
340 pub fn new(raw: impl Into<String>) -> Self {
342 let raw = raw.into();
343 let date = Date::parse(raw.trim());
344 Self { raw, date }
345 }
346
347 #[must_use]
349 pub const fn is_valid(&self) -> bool {
350 self.date.is_some()
351 }
352
353 #[must_use]
366 pub fn effective_date(&self) -> Option<Date> {
367 self.date
368 .or_else(|| DateTime::parse(self.raw.trim()).map(|parsed| parsed.date))
369 }
370}
371
372impl fmt::Display for DateField {
373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 f.write_str(&self.raw)
375 }
376}
377
378#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct DateTimeField {
386 pub raw: String,
388 pub datetime: Option<DateTime>,
392}
393
394impl DateTimeField {
395 pub fn new(raw: impl Into<String>) -> Self {
397 let raw = raw.into();
398 let datetime = DateTime::parse(&raw);
399 Self { raw, datetime }
400 }
401
402 #[must_use]
405 pub const fn is_valid(&self) -> bool {
406 self.has_time() && self.has_offset()
407 }
408
409 #[must_use]
415 pub const fn has_time(&self) -> bool {
416 match self.datetime {
417 Some(datetime) => datetime.has_time,
418 None => false,
419 }
420 }
421
422 #[must_use]
424 pub const fn has_offset(&self) -> bool {
425 match self.datetime {
426 Some(datetime) => datetime.offset_minutes.is_some(),
427 None => false,
428 }
429 }
430}
431
432impl fmt::Display for DateTimeField {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 f.write_str(&self.raw)
435 }
436}
437
438fn expect<'a>(s: &mut &'a str, c: char) -> Option<()> {
439 let rest: &'a str = (*s).strip_prefix(c)?;
440 *s = rest;
441 Some(())
442}
443
444fn take_u32<'a>(s: &mut &'a str, n: usize) -> Option<u32> {
446 let src: &'a str = s;
447 if src.len() < n || !src.as_bytes()[..n].iter().all(u8::is_ascii_digit) {
448 return None;
449 }
450 let value = src[..n].parse().ok()?;
451 *s = &src[n..];
452 Some(value)
453}
454
455#[allow(clippy::option_option)]
462fn parse_offset(s: &str) -> Option<Option<i32>> {
463 if s.is_empty() {
464 return Some(None);
465 }
466 if s.eq_ignore_ascii_case("z") {
467 return Some(Some(0));
468 }
469 let (sign, rest) = match s.as_bytes()[0] {
470 b'+' => (1, &s[1..]),
471 b'-' => (-1, &s[1..]),
472 _ => return None,
473 };
474 let mut rest = rest;
475 let hours = take_u32(&mut rest, 2)?;
476 let minutes = if rest.is_empty() {
477 0
478 } else {
479 let _ = expect(&mut rest, ':');
480 take_u32(&mut rest, 2)?
481 };
482 if !rest.is_empty() || hours > 23 || minutes > 59 {
483 return None;
484 }
485 let h = i32::try_from(hours).expect("hours bounded to 23");
488 let m = i32::try_from(minutes).expect("minutes bounded to 59");
489 Some(Some(sign * (h * 60 + m)))
490}
491
492const fn is_leap_year(year: i32) -> bool {
493 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
494}
495
496const fn days_in_month(year: i32, month: u32) -> u32 {
497 match month {
498 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
499 4 | 6 | 9 | 11 => 30,
500 2 if is_leap_year(year) => 29,
501 2 => 28,
502 _ => 0,
503 }
504}
505
506fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
508 let y = i64::from(y) - i64::from(m <= 2);
509 let era = if y >= 0 { y } else { y - 399 } / 400;
510 let yoe = y - era * 400; let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
515}
516
517#[allow(
523 clippy::cast_possible_truncation,
524 clippy::cast_sign_loss,
525 clippy::cast_possible_wrap
526)]
527fn civil_from_days(z: i64) -> (i32, u32, u32) {
528 let z = z + 719_468;
529 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
530 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
533 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; ((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn parses_plain_dates() {
546 assert_eq!(Date::parse("2026-09-23"), Date::new(2026, 9, 23));
547 assert_eq!(Date::parse("2024-02-29"), Date::new(2024, 2, 29));
548 assert_eq!(Date::parse("2026-02-29"), None); assert_eq!(Date::parse("2026-13-01"), None);
550 assert_eq!(Date::parse("2026-9-23"), None); assert_eq!(Date::parse("2026-09-23T00:00:00Z"), None);
552 }
553
554 #[test]
555 fn epoch_roundtrip() {
556 for days in [-40_000_i64, -1, 0, 1, 20_000, 100_000] {
557 let d = Date::from_days_since_epoch(days);
558 assert_eq!(d.days_since_epoch(), days, "{d}");
559 }
560 assert_eq!(Date::from_days_since_epoch(0).to_string(), "1970-01-01");
561 }
562
563 #[test]
564 fn parses_datetimes_with_zones() {
565 let z = DateTime::parse("2026-06-20T22:53:05Z").unwrap();
566 assert_eq!(z.offset_minutes, Some(0));
567 assert_eq!(z.to_string(), "2026-06-20T22:53:05Z");
568
569 let offset = DateTime::parse("2026-05-28T22:53:05+00:00").unwrap();
570 assert_eq!(
571 offset.to_utc_seconds(),
572 DateTime::parse("2026-05-28T22:53:05Z")
573 .unwrap()
574 .to_utc_seconds()
575 );
576
577 let a = DateTime::parse("2026-06-25T09:00:00+02:00").unwrap();
579 let b = DateTime::parse("2026-06-25T07:00:00Z").unwrap();
580 assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
581
582 assert!(DateTime::parse("2026-06-20 22:53:05").unwrap().has_time);
583 assert!(!DateTime::parse("2026-06-20").unwrap().has_time);
584 assert_eq!(DateTime::parse("2026-06-20T22:53").unwrap().second, 0);
585 assert_eq!(
586 DateTime::parse("2026-06-20T22:53:05.25Z")
587 .unwrap()
588 .nanosecond,
589 250_000_000
590 );
591 assert_eq!(DateTime::parse("2026-06-20T25:00:00Z"), None);
592 assert_eq!(DateTime::parse("not a date"), None);
593 }
594
595 #[test]
596 fn offsets_order_across_midnight() {
597 let late = DateTime::parse("2026-06-20T23:00:00-05:00").unwrap();
598 assert_eq!(late.utc_date(), Date::new(2026, 6, 21).unwrap());
599 assert!(late > DateTime::parse("2026-06-21T03:00:00Z").unwrap());
600 }
601
602 #[test]
603 fn fields_keep_raw_text() {
604 let bad = DateField::new("last tuesday");
605 assert!(!bad.is_valid());
606 assert_eq!(bad.raw, "last tuesday");
607
608 let good = DateTimeField::new("2026-06-25T09:00:00Z");
609 assert!(good.is_valid());
610 assert!(good.has_time());
611 assert!(good.has_offset());
612 assert_eq!(good.datetime.unwrap().date, Date::new(2026, 6, 25).unwrap());
613
614 let no_offset = DateTimeField::new("2026-06-25T09:00:00");
615 assert!(!no_offset.is_valid());
616 assert!(no_offset.has_time());
617 assert!(!no_offset.has_offset());
618
619 let date_only = DateTimeField::new("2026-06-25");
620 assert!(!date_only.is_valid());
621 assert!(!date_only.has_time());
622 assert!(!date_only.has_offset());
623 }
624}