1use std::fmt;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct Date {
27 pub year: i32,
29 pub month: u32,
31 pub day: u32,
33}
34
35impl Date {
36 #[must_use]
39 pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
40 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
41 return None;
42 }
43 Some(Self { year, month, day })
44 }
45
46 #[must_use]
49 pub fn parse(s: &str) -> Option<Self> {
50 let b = s.as_bytes();
51 if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
52 return None;
53 }
54 if !b
55 .iter()
56 .enumerate()
57 .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
58 {
59 return None;
60 }
61 Self::new(
62 s[0..4].parse().ok()?,
63 s[5..7].parse().ok()?,
64 s[8..10].parse().ok()?,
65 )
66 }
67
68 #[must_use]
72 pub fn today_utc() -> Option<Self> {
73 let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
74 let secs = i64::try_from(secs).unwrap_or(i64::MAX);
77 Some(Self::from_days_since_epoch(secs.div_euclid(86_400)))
78 }
79
80 #[must_use]
82 pub fn days_since_epoch(&self) -> i64 {
83 days_from_civil(self.year, self.month, self.day)
84 }
85
86 #[must_use]
88 pub fn from_days_since_epoch(days: i64) -> Self {
89 let (year, month, day) = civil_from_days(days);
90 Self { year, month, day }
91 }
92
93 #[must_use]
95 pub const fn to_utc_datetime(&self) -> DateTime {
96 DateTime::from_date_utc(*self)
97 }
98}
99
100impl fmt::Display for Date {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
103 }
104}
105
106impl std::str::FromStr for Date {
107 type Err = ParseDateError;
108 fn from_str(s: &str) -> Result<Self, Self::Err> {
109 Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
110 }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct ParseDateError(pub String);
116
117impl fmt::Display for ParseDateError {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 write!(f, "not an ISO-8601 date/datetime: {:?}", self.0)
120 }
121}
122
123impl std::error::Error for ParseDateError {}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
132pub struct DateTime {
133 pub date: Date,
135 pub hour: u32,
137 pub minute: u32,
139 pub second: u32,
141 pub nanosecond: u32,
143 pub offset_minutes: Option<i32>,
145 pub has_time: bool,
147}
148
149impl DateTime {
150 pub fn parse(s: &str) -> Option<Self> {
156 let s = s.trim();
157 if !s.is_ascii() || s.len() < 10 {
158 return None;
159 }
160 let date = Date::parse(&s[..10])?;
161 let rest = &s[10..];
162 if rest.is_empty() {
163 return Some(Self {
164 date,
165 hour: 0,
166 minute: 0,
167 second: 0,
168 nanosecond: 0,
169 offset_minutes: None,
170 has_time: false,
171 });
172 }
173
174 let sep = rest.as_bytes()[0];
175 if sep != b'T' && sep != b't' && sep != b' ' {
176 return None;
177 }
178 let mut rest = &rest[1..];
179
180 let hour = take_u32(&mut rest, 2)?;
181 expect(&mut rest, ':')?;
182 let minute = take_u32(&mut rest, 2)?;
183 let mut second = 0;
184 let mut nanosecond = 0;
185 if rest.starts_with(':') {
186 rest = &rest[1..];
187 second = take_u32(&mut rest, 2)?;
188 if rest.starts_with('.') || rest.starts_with(',') {
189 rest = &rest[1..];
190 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
191 if digits.is_empty() {
192 return None;
193 }
194 rest = &rest[digits.len()..];
195 let mut nanos = digits;
197 nanos.truncate(9);
198 while nanos.len() < 9 {
199 nanos.push('0');
200 }
201 nanosecond = nanos.parse().ok()?;
202 }
203 }
204 if hour > 23 || minute > 59 || second > 60 {
205 return None;
206 }
207
208 let offset_minutes = parse_offset(rest)?;
209 Some(Self {
210 date,
211 hour,
212 minute,
213 second,
214 nanosecond,
215 offset_minutes,
216 has_time: true,
217 })
218 }
219
220 #[must_use]
223 pub fn to_utc_seconds(&self) -> i64 {
224 self.date.days_since_epoch() * 86_400
225 + i64::from(self.hour) * 3600
226 + i64::from(self.minute) * 60
227 + i64::from(self.second)
228 - i64::from(self.offset_minutes.unwrap_or(0)) * 60
229 }
230
231 #[must_use]
234 pub fn utc_date(&self) -> Date {
235 Date::from_days_since_epoch(self.to_utc_seconds().div_euclid(86_400))
236 }
237
238 #[must_use]
240 pub const fn has_offset(&self) -> bool {
241 self.offset_minutes.is_some()
242 }
243
244 #[must_use]
246 pub fn now_utc() -> Option<Self> {
247 let duration = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
248 let secs = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
249 let nanos = duration.subsec_nanos();
250 let days = secs.div_euclid(86_400);
251 let rem_secs = secs.rem_euclid(86_400);
252 let hour = u32::try_from(rem_secs / 3600).ok()?;
253 let minute = u32::try_from((rem_secs % 3600) / 60).ok()?;
254 let second = u32::try_from(rem_secs % 60).ok()?;
255 Some(Self {
256 date: Date::from_days_since_epoch(days),
257 hour,
258 minute,
259 second,
260 nanosecond: nanos,
261 offset_minutes: Some(0),
262 has_time: true,
263 })
264 }
265
266 #[must_use]
268 pub const fn from_date_utc(date: Date) -> Self {
269 Self {
270 date,
271 hour: 0,
272 minute: 0,
273 second: 0,
274 nanosecond: 0,
275 offset_minutes: Some(0),
276 has_time: true,
277 }
278 }
279}
280
281#[must_use]
289pub fn normalize_iso_datetime(s: &str) -> Option<String> {
290 let clean = s.trim().trim_matches(|c| c == '\'' || c == '"').trim();
291 let dt = DateTime::parse(clean)?;
292 if !dt.has_time {
293 Some(format!("{}T00:00:00Z", dt.date))
294 } else if dt.offset_minutes.is_none() {
295 let mut normalized = dt;
296 normalized.offset_minutes = Some(0);
297 Some(normalized.to_string())
298 } else {
299 Some(dt.to_string())
300 }
301}
302
303impl PartialOrd for DateTime {
304 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
305 Some(self.cmp(other))
306 }
307}
308
309impl Ord for DateTime {
310 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
311 self.to_utc_seconds()
312 .cmp(&other.to_utc_seconds())
313 .then(self.nanosecond.cmp(&other.nanosecond))
314 }
315}
316
317impl fmt::Display for DateTime {
318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319 write!(f, "{}", self.date)?;
320 if !self.has_time {
321 return Ok(());
322 }
323 write!(f, "T{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
324 if self.nanosecond > 0 {
325 let frac = format!("{:09}", self.nanosecond);
326 write!(f, ".{}", frac.trim_end_matches('0'))?;
327 }
328 match self.offset_minutes {
329 None => Ok(()),
330 Some(0) => f.write_str("Z"),
331 Some(m) => {
332 let sign = if m < 0 { '-' } else { '+' };
333 write!(f, "{sign}{:02}:{:02}", m.abs() / 60, m.abs() % 60)
334 }
335 }
336 }
337}
338
339impl std::str::FromStr for DateTime {
340 type Err = ParseDateError;
341 fn from_str(s: &str) -> Result<Self, Self::Err> {
342 Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
343 }
344}
345
346#[derive(Clone, Debug, PartialEq, Eq)]
353pub struct DateField {
354 pub raw: String,
356 pub date: Option<Date>,
358}
359
360impl DateField {
361 pub fn new(raw: impl Into<String>) -> Self {
363 let raw = raw.into();
364 let date = Date::parse(raw.trim());
365 Self { raw, date }
366 }
367
368 #[must_use]
370 pub const fn is_valid(&self) -> bool {
371 self.date.is_some()
372 }
373
374 #[must_use]
387 pub fn effective_date(&self) -> Option<Date> {
388 self.date
389 .or_else(|| DateTime::parse(self.raw.trim()).map(|parsed| parsed.date))
390 }
391}
392
393impl fmt::Display for DateField {
394 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395 f.write_str(&self.raw)
396 }
397}
398
399#[derive(Clone, Debug, PartialEq, Eq)]
406pub struct DateTimeField {
407 pub raw: String,
409 pub datetime: Option<DateTime>,
413}
414
415impl DateTimeField {
416 pub fn new(raw: impl Into<String>) -> Self {
418 let raw = raw.into();
419 let datetime = DateTime::parse(&raw);
420 Self { raw, datetime }
421 }
422
423 #[must_use]
426 pub const fn is_valid(&self) -> bool {
427 self.has_time() && self.has_offset()
428 }
429
430 #[must_use]
436 pub const fn has_time(&self) -> bool {
437 match self.datetime {
438 Some(datetime) => datetime.has_time,
439 None => false,
440 }
441 }
442
443 #[must_use]
445 pub const fn has_offset(&self) -> bool {
446 match self.datetime {
447 Some(datetime) => datetime.offset_minutes.is_some(),
448 None => false,
449 }
450 }
451}
452
453impl fmt::Display for DateTimeField {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 f.write_str(&self.raw)
456 }
457}
458
459fn expect<'a>(s: &mut &'a str, c: char) -> Option<()> {
460 let rest: &'a str = (*s).strip_prefix(c)?;
461 *s = rest;
462 Some(())
463}
464
465fn take_u32<'a>(s: &mut &'a str, n: usize) -> Option<u32> {
467 let src: &'a str = s;
468 if src.len() < n || !src.as_bytes()[..n].iter().all(u8::is_ascii_digit) {
469 return None;
470 }
471 let value = src[..n].parse().ok()?;
472 *s = &src[n..];
473 Some(value)
474}
475
476#[allow(clippy::option_option)]
483fn parse_offset(s: &str) -> Option<Option<i32>> {
484 if s.is_empty() {
485 return Some(None);
486 }
487 if s.eq_ignore_ascii_case("z") {
488 return Some(Some(0));
489 }
490 let (sign, rest) = match s.as_bytes()[0] {
491 b'+' => (1, &s[1..]),
492 b'-' => (-1, &s[1..]),
493 _ => return None,
494 };
495 let mut rest = rest;
496 let hours = take_u32(&mut rest, 2)?;
497 let minutes = if rest.is_empty() {
498 0
499 } else {
500 let _ = expect(&mut rest, ':');
501 take_u32(&mut rest, 2)?
502 };
503 if !rest.is_empty() || hours > 23 || minutes > 59 {
504 return None;
505 }
506 let h = i32::try_from(hours).expect("hours bounded to 23");
509 let m = i32::try_from(minutes).expect("minutes bounded to 59");
510 Some(Some(sign * (h * 60 + m)))
511}
512
513const fn is_leap_year(year: i32) -> bool {
514 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
515}
516
517const fn days_in_month(year: i32, month: u32) -> u32 {
518 match month {
519 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
520 4 | 6 | 9 | 11 => 30,
521 2 if is_leap_year(year) => 29,
522 2 => 28,
523 _ => 0,
524 }
525}
526
527fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
529 let y = i64::from(y) - i64::from(m <= 2);
530 let era = if y >= 0 { y } else { y - 399 } / 400;
531 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
536}
537
538#[allow(
544 clippy::cast_possible_truncation,
545 clippy::cast_sign_loss,
546 clippy::cast_possible_wrap
547)]
548fn civil_from_days(z: i64) -> (i32, u32, u32) {
549 let z = z + 719_468;
550 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
551 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
554 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)
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn parses_plain_dates() {
567 assert_eq!(Date::parse("2026-09-23"), Date::new(2026, 9, 23));
568 assert_eq!(Date::parse("2024-02-29"), Date::new(2024, 2, 29));
569 assert_eq!(Date::parse("2026-02-29"), None); assert_eq!(Date::parse("2026-13-01"), None);
571 assert_eq!(Date::parse("2026-9-23"), None); assert_eq!(Date::parse("2026-09-23T00:00:00Z"), None);
573 }
574
575 #[test]
576 fn epoch_roundtrip() {
577 for days in [-40_000_i64, -1, 0, 1, 20_000, 100_000] {
578 let d = Date::from_days_since_epoch(days);
579 assert_eq!(d.days_since_epoch(), days, "{d}");
580 }
581 assert_eq!(Date::from_days_since_epoch(0).to_string(), "1970-01-01");
582 }
583
584 #[test]
585 fn parses_datetimes_with_zones() {
586 let z = DateTime::parse("2026-06-20T22:53:05Z").unwrap();
587 assert_eq!(z.offset_minutes, Some(0));
588 assert_eq!(z.to_string(), "2026-06-20T22:53:05Z");
589
590 let offset = DateTime::parse("2026-05-28T22:53:05+00:00").unwrap();
591 assert_eq!(
592 offset.to_utc_seconds(),
593 DateTime::parse("2026-05-28T22:53:05Z")
594 .unwrap()
595 .to_utc_seconds()
596 );
597
598 let a = DateTime::parse("2026-06-25T09:00:00+02:00").unwrap();
600 let b = DateTime::parse("2026-06-25T07:00:00Z").unwrap();
601 assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
602
603 assert!(DateTime::parse("2026-06-20 22:53:05").unwrap().has_time);
604 assert!(!DateTime::parse("2026-06-20").unwrap().has_time);
605 assert_eq!(DateTime::parse("2026-06-20T22:53").unwrap().second, 0);
606 assert_eq!(
607 DateTime::parse("2026-06-20T22:53:05.25Z")
608 .unwrap()
609 .nanosecond,
610 250_000_000
611 );
612 assert_eq!(DateTime::parse("2026-06-20T25:00:00Z"), None);
613 assert_eq!(DateTime::parse("not a date"), None);
614 }
615
616 #[test]
617 fn offsets_order_across_midnight() {
618 let late = DateTime::parse("2026-06-20T23:00:00-05:00").unwrap();
619 assert_eq!(late.utc_date(), Date::new(2026, 6, 21).unwrap());
620 assert!(late > DateTime::parse("2026-06-21T03:00:00Z").unwrap());
621 }
622
623 #[test]
624 fn fields_keep_raw_text() {
625 let bad = DateField::new("last tuesday");
626 assert!(!bad.is_valid());
627 assert_eq!(bad.raw, "last tuesday");
628
629 let good = DateTimeField::new("2026-06-25T09:00:00Z");
630 assert!(good.is_valid());
631 assert!(good.has_time());
632 assert!(good.has_offset());
633 assert_eq!(good.datetime.unwrap().date, Date::new(2026, 6, 25).unwrap());
634
635 let no_offset = DateTimeField::new("2026-06-25T09:00:00");
636 assert!(!no_offset.is_valid());
637 assert!(no_offset.has_time());
638 assert!(!no_offset.has_offset());
639
640 let date_only = DateTimeField::new("2026-06-25");
641 assert!(!date_only.is_valid());
642 assert!(!date_only.has_time());
643 assert!(!date_only.has_offset());
644 }
645
646 #[test]
647 fn test_normalize_iso_datetime() {
648 assert_eq!(
649 normalize_iso_datetime("2026-06-30"),
650 Some("2026-06-30T00:00:00Z".to_string())
651 );
652 assert_eq!(
653 normalize_iso_datetime("'2026-06-30'"),
654 Some("2026-06-30T00:00:00Z".to_string())
655 );
656 assert_eq!(
657 normalize_iso_datetime("\"2026-06-30\""),
658 Some("2026-06-30T00:00:00Z".to_string())
659 );
660 assert_eq!(
661 normalize_iso_datetime("2026-06-30T14:20:00"),
662 Some("2026-06-30T14:20:00Z".to_string())
663 );
664 assert_eq!(
665 normalize_iso_datetime("2026-06-30 14:20:00"),
666 Some("2026-06-30T14:20:00Z".to_string())
667 );
668 assert_eq!(
669 normalize_iso_datetime("2026-06-30T14:20:00Z"),
670 Some("2026-06-30T14:20:00Z".to_string())
671 );
672 assert_eq!(
673 normalize_iso_datetime("2026-06-30T14:20:00+02:00"),
674 Some("2026-06-30T14:20:00+02:00".to_string())
675 );
676 assert_eq!(normalize_iso_datetime("last tuesday"), None);
677 }
678}