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
281impl PartialOrd for DateTime {
282 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
283 Some(self.cmp(other))
284 }
285}
286
287impl Ord for DateTime {
288 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
289 self.to_utc_seconds()
290 .cmp(&other.to_utc_seconds())
291 .then(self.nanosecond.cmp(&other.nanosecond))
292 }
293}
294
295impl fmt::Display for DateTime {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 write!(f, "{}", self.date)?;
298 if !self.has_time {
299 return Ok(());
300 }
301 write!(f, "T{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
302 if self.nanosecond > 0 {
303 let frac = format!("{:09}", self.nanosecond);
304 write!(f, ".{}", frac.trim_end_matches('0'))?;
305 }
306 match self.offset_minutes {
307 None => Ok(()),
308 Some(0) => f.write_str("Z"),
309 Some(m) => {
310 let sign = if m < 0 { '-' } else { '+' };
311 write!(f, "{sign}{:02}:{:02}", m.abs() / 60, m.abs() % 60)
312 }
313 }
314 }
315}
316
317impl std::str::FromStr for DateTime {
318 type Err = ParseDateError;
319 fn from_str(s: &str) -> Result<Self, Self::Err> {
320 Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
321 }
322}
323
324#[derive(Clone, Debug, PartialEq, Eq)]
331pub struct DateField {
332 pub raw: String,
334 pub date: Option<Date>,
336}
337
338impl DateField {
339 pub fn new(raw: impl Into<String>) -> Self {
341 let raw = raw.into();
342 let date = Date::parse(raw.trim());
343 Self { raw, date }
344 }
345
346 #[must_use]
348 pub const fn is_valid(&self) -> bool {
349 self.date.is_some()
350 }
351
352 #[must_use]
365 pub fn effective_date(&self) -> Option<Date> {
366 self.date
367 .or_else(|| DateTime::parse(self.raw.trim()).map(|parsed| parsed.date))
368 }
369}
370
371impl fmt::Display for DateField {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 f.write_str(&self.raw)
374 }
375}
376
377#[derive(Clone, Debug, PartialEq, Eq)]
384pub struct DateTimeField {
385 pub raw: String,
387 pub datetime: Option<DateTime>,
391}
392
393impl DateTimeField {
394 pub fn new(raw: impl Into<String>) -> Self {
396 let raw = raw.into();
397 let datetime = DateTime::parse(&raw);
398 Self { raw, datetime }
399 }
400
401 #[must_use]
404 pub const fn is_valid(&self) -> bool {
405 self.has_time() && self.has_offset()
406 }
407
408 #[must_use]
414 pub const fn has_time(&self) -> bool {
415 match self.datetime {
416 Some(datetime) => datetime.has_time,
417 None => false,
418 }
419 }
420
421 #[must_use]
423 pub const fn has_offset(&self) -> bool {
424 match self.datetime {
425 Some(datetime) => datetime.offset_minutes.is_some(),
426 None => false,
427 }
428 }
429}
430
431impl fmt::Display for DateTimeField {
432 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433 f.write_str(&self.raw)
434 }
435}
436
437fn expect<'a>(s: &mut &'a str, c: char) -> Option<()> {
438 let rest: &'a str = (*s).strip_prefix(c)?;
439 *s = rest;
440 Some(())
441}
442
443fn take_u32<'a>(s: &mut &'a str, n: usize) -> Option<u32> {
445 let src: &'a str = s;
446 if src.len() < n || !src.as_bytes()[..n].iter().all(u8::is_ascii_digit) {
447 return None;
448 }
449 let value = src[..n].parse().ok()?;
450 *s = &src[n..];
451 Some(value)
452}
453
454#[allow(clippy::option_option)]
461fn parse_offset(s: &str) -> Option<Option<i32>> {
462 if s.is_empty() {
463 return Some(None);
464 }
465 if s.eq_ignore_ascii_case("z") {
466 return Some(Some(0));
467 }
468 let (sign, rest) = match s.as_bytes()[0] {
469 b'+' => (1, &s[1..]),
470 b'-' => (-1, &s[1..]),
471 _ => return None,
472 };
473 let mut rest = rest;
474 let hours = take_u32(&mut rest, 2)?;
475 let minutes = if rest.is_empty() {
476 0
477 } else {
478 let _ = expect(&mut rest, ':');
479 take_u32(&mut rest, 2)?
480 };
481 if !rest.is_empty() || hours > 23 || minutes > 59 {
482 return None;
483 }
484 let h = i32::try_from(hours).expect("hours bounded to 23");
487 let m = i32::try_from(minutes).expect("minutes bounded to 59");
488 Some(Some(sign * (h * 60 + m)))
489}
490
491const fn is_leap_year(year: i32) -> bool {
492 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
493}
494
495const fn days_in_month(year: i32, month: u32) -> u32 {
496 match month {
497 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
498 4 | 6 | 9 | 11 => 30,
499 2 if is_leap_year(year) => 29,
500 2 => 28,
501 _ => 0,
502 }
503}
504
505fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
507 let y = i64::from(y) - i64::from(m <= 2);
508 let era = if y >= 0 { y } else { y - 399 } / 400;
509 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
514}
515
516#[allow(
522 clippy::cast_possible_truncation,
523 clippy::cast_sign_loss,
524 clippy::cast_possible_wrap
525)]
526fn civil_from_days(z: i64) -> (i32, u32, u32) {
527 let z = z + 719_468;
528 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
529 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
532 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)
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn parses_plain_dates() {
545 assert_eq!(Date::parse("2026-09-23"), Date::new(2026, 9, 23));
546 assert_eq!(Date::parse("2024-02-29"), Date::new(2024, 2, 29));
547 assert_eq!(Date::parse("2026-02-29"), None); assert_eq!(Date::parse("2026-13-01"), None);
549 assert_eq!(Date::parse("2026-9-23"), None); assert_eq!(Date::parse("2026-09-23T00:00:00Z"), None);
551 }
552
553 #[test]
554 fn epoch_roundtrip() {
555 for days in [-40_000_i64, -1, 0, 1, 20_000, 100_000] {
556 let d = Date::from_days_since_epoch(days);
557 assert_eq!(d.days_since_epoch(), days, "{d}");
558 }
559 assert_eq!(Date::from_days_since_epoch(0).to_string(), "1970-01-01");
560 }
561
562 #[test]
563 fn parses_datetimes_with_zones() {
564 let z = DateTime::parse("2026-06-20T22:53:05Z").unwrap();
565 assert_eq!(z.offset_minutes, Some(0));
566 assert_eq!(z.to_string(), "2026-06-20T22:53:05Z");
567
568 let offset = DateTime::parse("2026-05-28T22:53:05+00:00").unwrap();
569 assert_eq!(
570 offset.to_utc_seconds(),
571 DateTime::parse("2026-05-28T22:53:05Z")
572 .unwrap()
573 .to_utc_seconds()
574 );
575
576 let a = DateTime::parse("2026-06-25T09:00:00+02:00").unwrap();
578 let b = DateTime::parse("2026-06-25T07:00:00Z").unwrap();
579 assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
580
581 assert!(DateTime::parse("2026-06-20 22:53:05").unwrap().has_time);
582 assert!(!DateTime::parse("2026-06-20").unwrap().has_time);
583 assert_eq!(DateTime::parse("2026-06-20T22:53").unwrap().second, 0);
584 assert_eq!(
585 DateTime::parse("2026-06-20T22:53:05.25Z")
586 .unwrap()
587 .nanosecond,
588 250_000_000
589 );
590 assert_eq!(DateTime::parse("2026-06-20T25:00:00Z"), None);
591 assert_eq!(DateTime::parse("not a date"), None);
592 }
593
594 #[test]
595 fn offsets_order_across_midnight() {
596 let late = DateTime::parse("2026-06-20T23:00:00-05:00").unwrap();
597 assert_eq!(late.utc_date(), Date::new(2026, 6, 21).unwrap());
598 assert!(late > DateTime::parse("2026-06-21T03:00:00Z").unwrap());
599 }
600
601 #[test]
602 fn fields_keep_raw_text() {
603 let bad = DateField::new("last tuesday");
604 assert!(!bad.is_valid());
605 assert_eq!(bad.raw, "last tuesday");
606
607 let good = DateTimeField::new("2026-06-25T09:00:00Z");
608 assert!(good.is_valid());
609 assert!(good.has_time());
610 assert!(good.has_offset());
611 assert_eq!(good.datetime.unwrap().date, Date::new(2026, 6, 25).unwrap());
612
613 let no_offset = DateTimeField::new("2026-06-25T09:00:00");
614 assert!(!no_offset.is_valid());
615 assert!(no_offset.has_time());
616 assert!(!no_offset.has_offset());
617
618 let date_only = DateTimeField::new("2026-06-25");
619 assert!(!date_only.is_valid());
620 assert!(!date_only.has_time());
621 assert!(!date_only.has_offset());
622 }
623}