use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DateError {
#[error("{value:?} is not an ISO 8601 date of the form YYYY-MM-DD")]
Malformed {
value: String,
},
#[error("{year:04}-{month:02}-{day:02} is not a real calendar date")]
NotACalendarDate {
year: i64,
month: u32,
day: u32,
},
#[error("year {year} is outside the supported range 1-9999")]
YearOutOfRange {
year: i64,
},
#[error("{days} days from the epoch is outside 0001-01-01 - 9999-12-31")]
OutOfRange {
days: i64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DateTimeError {
#[error(transparent)]
Date(#[from] DateError),
#[error("{value:?} is not an ISO 8601 timestamp of the form YYYY-MM-DDThh:mm:ss")]
Malformed {
value: String,
},
#[error("{hour:02}:{minute:02}:{second:02} is not a valid time of day")]
NotATimeOfDay {
hour: u32,
minute: u32,
second: u32,
},
}
#[cfg(any(feature = "time", feature = "chrono"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConversionError {
#[error("timestamp has no UTC offset, so it names no instant — see IsoDateTime::in_utc")]
NoOffset,
#[error("the value is outside the target type's range")]
OutOfRange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IsoDate {
year: u16,
month: u8,
day: u8,
}
impl IsoDate {
pub const MIN: Self = Self {
year: 1,
month: 1,
day: 1,
};
pub const MAX: Self = Self {
year: 9999,
month: 12,
day: 31,
};
pub fn new(year: u16, month: u8, day: u8) -> Result<Self, DateError> {
if !(1..=9999).contains(&year) {
return Err(DateError::YearOutOfRange {
year: i64::from(year),
});
}
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(DateError::NotACalendarDate {
year: i64::from(year),
month: u32::from(month),
day: u32::from(day),
});
}
Ok(Self { year, month, day })
}
pub fn parse(s: &str) -> Result<Self, DateError> {
let (head, zone) = s.split_at_checked(10).unwrap_or((s, ""));
if !is_xsd_timezone(zone) {
return Err(DateError::Malformed {
value: s.to_owned(),
});
}
let [y0, y1, y2, y3, b'-', m0, m1, b'-', d0, d1] = *head.as_bytes() else {
return Err(DateError::Malformed {
value: s.to_owned(),
});
};
if ![y0, y1, y2, y3, m0, m1, d0, d1]
.iter()
.all(u8::is_ascii_digit)
{
return Err(DateError::Malformed {
value: s.to_owned(),
});
}
let d2 = |a: u8, b: u8| u16::from(a - b'0') * 10 + u16::from(b - b'0');
let year = d2(y0, y1) * 100 + d2(y2, y3);
#[allow(clippy::items_after_statements)]
#[allow(clippy::cast_possible_truncation)]
Self::new(year, d2(m0, m1) as u8, d2(d0, d1) as u8)
}
pub fn parse_date_part(s: &str) -> Result<Self, DateError> {
let Some((head, rest)) = s.split_at_checked(10) else {
return Self::parse(s);
};
match rest.as_bytes().first() {
Some(b'T') => {
IsoDateTime::parse(s).map_err(|_| DateError::Malformed {
value: s.to_owned(),
})?;
Self::parse(head)
}
_ => Self::parse(s),
}
}
#[inline]
#[must_use]
pub const fn year(self) -> u16 {
self.year
}
#[inline]
#[must_use]
pub const fn month(self) -> u8 {
self.month
}
#[inline]
#[must_use]
pub const fn day(self) -> u8 {
self.day
}
#[inline]
#[must_use]
pub const fn is_leap_year(self) -> bool {
is_leap(self.year)
}
#[must_use]
pub fn today() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
Self::from_epoch_days(days).unwrap_or(Self::MAX)
}
#[allow(
clippy::similar_names,
reason = "names taken from the source algorithm"
)]
#[must_use]
pub const fn epoch_days(self) -> i64 {
let y = self.year as i64 - if self.month <= 2 { 1 } else { 0 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let m = self.month as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + self.day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[allow(
clippy::similar_names,
reason = "names taken from the source algorithm"
)]
pub fn from_epoch_days(days: i64) -> Result<Self, DateError> {
const MIN_DAYS: i64 = -719_162; const MAX_DAYS: i64 = 2_932_896; if !(MIN_DAYS..=MAX_DAYS).contains(&days) {
return Err(DateError::OutOfRange { days });
}
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
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 };
let y = y + i64::from(m <= 2);
let year = u16::try_from(y).map_err(|_| DateError::YearOutOfRange { year: y })?;
let month = u8::try_from(m).map_err(|_| DateError::YearOutOfRange { year: y })?;
let day = u8::try_from(d).map_err(|_| DateError::YearOutOfRange { year: y })?;
Self::new(year, month, day)
}
pub fn plus_days(self, days: i64) -> Result<Self, DateError> {
Self::from_epoch_days(self.epoch_days().saturating_add(days))
}
pub(crate) fn write_to<W: fmt::Write>(self, w: &mut W) -> fmt::Result {
write!(w, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
impl fmt::Display for IsoDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.write_to(f)
}
}
impl FromStr for IsoDate {
type Err = DateError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<&str> for IsoDate {
type Error = DateError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
fn is_xsd_timezone(s: &str) -> bool {
match s.as_bytes() {
[] | [b'Z'] => true,
[b'+' | b'-', h0, h1, b':', m0, m1] => {
if ![h0, h1, m0, m1].iter().all(|b| b.is_ascii_digit()) {
return false;
}
let hours = i16::from(h0 - b'0') * 10 + i16::from(h1 - b'0');
let minutes = i16::from(m0 - b'0') * 10 + i16::from(m1 - b'0');
hours <= 14 && minutes <= 59 && (hours < 14 || minutes == 0)
}
_ => false,
}
}
const fn is_leap(year: u16) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
const fn days_in_month(year: u16, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IsoDateTime {
date: IsoDate,
hour: u8,
minute: u8,
second: u8,
offset_minutes: Option<i16>,
}
impl IsoDateTime {
pub fn new(date: IsoDate, hour: u8, minute: u8, second: u8) -> Result<Self, DateTimeError> {
if hour > 23 || minute > 59 || second > 59 {
return Err(DateTimeError::NotATimeOfDay {
hour: u32::from(hour),
minute: u32::from(minute),
second: u32::from(second),
});
}
Ok(Self {
date,
hour,
minute,
second,
offset_minutes: None,
})
}
#[must_use]
pub const fn in_utc(mut self) -> Self {
self.offset_minutes = Some(0);
self
}
#[must_use]
pub fn now() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX);
let date = IsoDate::from_epoch_days(days).unwrap_or(IsoDate::MAX);
#[allow(clippy::cast_possible_truncation)]
Self {
date,
hour: ((secs / 3600) % 24) as u8,
minute: ((secs / 60) % 60) as u8,
second: (secs % 60) as u8,
offset_minutes: None,
}
}
pub fn parse(s: &str) -> Result<Self, DateTimeError> {
let malformed = || DateTimeError::Malformed {
value: s.to_owned(),
};
let (date_part, rest) = s.split_once('T').ok_or_else(malformed)?;
let date = IsoDate::parse(date_part)?;
let offset_split = rest
.len()
.checked_sub(6)
.and_then(|at| rest.split_at_checked(at));
let (time_part, offset_minutes) = if let Some(head) = rest.strip_suffix('Z') {
(head, Some(0i16))
} else if let Some((head, tail)) = offset_split {
match parse_offset(tail) {
Some(off) => (head, Some(off)),
None => (rest, None),
}
} else {
(rest, None)
};
let time_part = time_part
.split_once('.')
.map_or(time_part, |(head, _)| head);
let [h0, h1, b':', m0, m1, b':', s0, s1] = *time_part.as_bytes() else {
return Err(malformed());
};
if ![h0, h1, m0, m1, s0, s1].iter().all(u8::is_ascii_digit) {
return Err(malformed());
}
let d2 = |a: u8, b: u8| (a - b'0') * 10 + (b - b'0');
let mut out = Self::new(date, d2(h0, h1), d2(m0, m1), d2(s0, s1))?;
out.offset_minutes = offset_minutes;
Ok(out)
}
#[inline]
#[must_use]
pub const fn date(self) -> IsoDate {
self.date
}
#[inline]
#[must_use]
pub const fn hour(self) -> u8 {
self.hour
}
#[inline]
#[must_use]
pub const fn minute(self) -> u8 {
self.minute
}
#[inline]
#[must_use]
pub const fn second(self) -> u8 {
self.second
}
#[inline]
#[must_use]
pub const fn offset_minutes(self) -> Option<i16> {
self.offset_minutes
}
#[must_use]
pub const fn unix_seconds(self) -> Option<i64> {
let Some(offset) = self.offset_minutes else {
return None;
};
let local = self.date.epoch_days() * 86_400
+ self.hour as i64 * 3600
+ self.minute as i64 * 60
+ self.second as i64;
Some(local - offset as i64 * 60)
}
#[must_use]
pub fn to_utc(self) -> Option<Self> {
let secs = self.unix_seconds()?;
let days = secs.div_euclid(86_400);
let rest = secs.rem_euclid(86_400);
let date = IsoDate::from_epoch_days(days).ok()?;
Some(Self {
date,
hour: u8::try_from(rest / 3600).ok()?,
minute: u8::try_from((rest / 60) % 60).ok()?,
second: u8::try_from(rest % 60).ok()?,
offset_minutes: Some(0),
})
}
#[cfg(any(feature = "time", feature = "chrono"))]
const fn with_time(mut self, hour: u8, minute: u8, second: u8) -> Self {
self.hour = hour;
self.minute = minute;
self.second = second;
self
}
}
fn parse_offset(tail: &str) -> Option<i16> {
let bytes = tail.as_bytes();
let [sign, h0, h1, b':', m0, m1] = *bytes else {
return None;
};
if !matches!(sign, b'+' | b'-') || ![h0, h1, m0, m1].iter().all(u8::is_ascii_digit) {
return None;
}
let hours = i16::from(h0 - b'0') * 10 + i16::from(h1 - b'0');
let minutes = i16::from(m0 - b'0') * 10 + i16::from(m1 - b'0');
let total = hours * 60 + minutes;
if minutes > 59 || total > 14 * 60 {
return None;
}
Some(if sign == b'-' { -total } else { total })
}
impl fmt::Display for IsoDateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.date.write_to(f)?;
write!(f, "T{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
match self.offset_minutes {
None => Ok(()),
Some(0) => f.write_str("Z"),
Some(off) => {
let sign = if off < 0 { '-' } else { '+' };
let abs = off.unsigned_abs();
write!(f, "{sign}{:02}:{:02}", abs / 60, abs % 60)
}
}
}
}
impl FromStr for IsoDateTime {
type Err = DateTimeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<&str> for IsoDateTime {
type Error = DateTimeError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl From<IsoDate> for IsoDateTime {
fn from(date: IsoDate) -> Self {
Self {
date,
hour: 0,
minute: 0,
second: 0,
offset_minutes: None,
}
}
}
#[cfg(feature = "serde")]
mod serde_impls {
use super::{IsoDate, IsoDateTime};
use serde::de::{Error as _, Unexpected};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
impl Serialize for IsoDate {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for IsoDate {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Self::parse(&raw)
.map_err(|_| D::Error::invalid_value(Unexpected::Str(&raw), &"a YYYY-MM-DD date"))
}
}
impl Serialize for IsoDateTime {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for IsoDateTime {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Self::parse(&raw).map_err(|_| {
D::Error::invalid_value(Unexpected::Str(&raw), &"a YYYY-MM-DDThh:mm:ss timestamp")
})
}
}
}
#[cfg(feature = "time")]
mod time_interop {
use super::{ConversionError, DateError, IsoDate, IsoDateTime};
impl TryFrom<time::Date> for IsoDate {
type Error = DateError;
fn try_from(d: time::Date) -> Result<Self, Self::Error> {
let year = d.year();
let year = u16::try_from(year).map_err(|_| DateError::YearOutOfRange {
year: i64::from(year),
})?;
Self::new(year, u8::from(d.month()), d.day())
}
}
impl TryFrom<IsoDate> for time::Date {
type Error = time::error::ComponentRange;
fn try_from(d: IsoDate) -> Result<Self, Self::Error> {
Self::from_calendar_date(
i32::from(d.year()),
time::Month::try_from(d.month())?,
d.day(),
)
}
}
impl TryFrom<time::PrimitiveDateTime> for IsoDateTime {
type Error = DateError;
fn try_from(t: time::PrimitiveDateTime) -> Result<Self, Self::Error> {
let date = IsoDate::try_from(t.date())?;
Ok(Self::from(date).with_time(t.hour(), t.minute(), t.second()))
}
}
impl TryFrom<IsoDateTime> for time::PrimitiveDateTime {
type Error = time::error::ComponentRange;
fn try_from(t: IsoDateTime) -> Result<Self, Self::Error> {
Ok(Self::new(
time::Date::try_from(t.date())?,
time::Time::from_hms(t.hour(), t.minute(), t.second())?,
))
}
}
impl TryFrom<time::OffsetDateTime> for IsoDateTime {
type Error = DateError;
fn try_from(t: time::OffsetDateTime) -> Result<Self, Self::Error> {
let date = IsoDate::try_from(t.date())?;
let mut out = Self::from(date).with_time(t.hour(), t.minute(), t.second());
let minutes = t.offset().whole_minutes();
if t.offset().whole_seconds() % 60 == 0 && minutes.abs() <= 14 * 60 {
out.offset_minutes = Some(minutes);
Ok(out)
} else {
let days = t.unix_timestamp().div_euclid(86_400);
let date = IsoDate::from_epoch_days(days)?;
let utc = t.to_offset(time::UtcOffset::UTC);
Ok(Self::from(date)
.with_time(utc.hour(), utc.minute(), utc.second())
.in_utc())
}
}
}
impl TryFrom<IsoDateTime> for time::OffsetDateTime {
type Error = ConversionError;
fn try_from(t: IsoDateTime) -> Result<Self, Self::Error> {
let minutes = t.offset_minutes().ok_or(ConversionError::NoOffset)?;
let offset = time::UtcOffset::from_whole_seconds(i32::from(minutes) * 60)
.map_err(|_| ConversionError::OutOfRange)?;
let naive =
time::PrimitiveDateTime::try_from(t).map_err(|_| ConversionError::OutOfRange)?;
Ok(naive.assume_offset(offset))
}
}
}
#[cfg(feature = "chrono")]
mod chrono_interop {
use super::{ConversionError, DateError, IsoDate, IsoDateTime};
use chrono::{Datelike, Timelike};
impl TryFrom<chrono::NaiveDate> for IsoDate {
type Error = DateError;
fn try_from(d: chrono::NaiveDate) -> Result<Self, Self::Error> {
let year = d.year();
let year = u16::try_from(year).map_err(|_| DateError::YearOutOfRange {
year: i64::from(year),
})?;
#[allow(clippy::cast_possible_truncation)]
Self::new(year, d.month() as u8, d.day() as u8)
}
}
impl TryFrom<IsoDate> for chrono::NaiveDate {
type Error = DateError;
fn try_from(d: IsoDate) -> Result<Self, Self::Error> {
Self::from_ymd_opt(
i32::from(d.year()),
u32::from(d.month()),
u32::from(d.day()),
)
.ok_or(DateError::NotACalendarDate {
year: i64::from(d.year()),
month: u32::from(d.month()),
day: u32::from(d.day()),
})
}
}
impl TryFrom<chrono::NaiveDateTime> for IsoDateTime {
type Error = DateError;
fn try_from(t: chrono::NaiveDateTime) -> Result<Self, Self::Error> {
let date = IsoDate::try_from(t.date())?;
#[allow(clippy::cast_possible_truncation)]
Ok(Self::from(date).with_time(t.hour() as u8, t.minute() as u8, t.second() as u8))
}
}
impl TryFrom<IsoDateTime> for chrono::NaiveDateTime {
type Error = DateError;
fn try_from(t: IsoDateTime) -> Result<Self, Self::Error> {
let date = chrono::NaiveDate::try_from(t.date())?;
date.and_hms_opt(
u32::from(t.hour()),
u32::from(t.minute()),
u32::from(t.second()),
)
.ok_or(DateError::NotACalendarDate {
year: i64::from(t.date().year()),
month: u32::from(t.date().month()),
day: u32::from(t.date().day()),
})
}
}
impl<Tz: chrono::TimeZone> TryFrom<chrono::DateTime<Tz>> for IsoDateTime {
type Error = DateError;
fn try_from(t: chrono::DateTime<Tz>) -> Result<Self, Self::Error> {
use chrono::Offset as _;
let fixed = t.offset().fix();
let seconds = fixed.local_minus_utc();
let naive = t
.naive_utc()
.checked_add_offset(fixed)
.ok_or(DateError::OutOfRange {
days: (t.timestamp() + i64::from(seconds)).div_euclid(86_400),
})?;
let mut out = Self::try_from(naive)?;
out.offset_minutes = i16::try_from(seconds / 60)
.ok()
.filter(|minutes| seconds % 60 == 0 && minutes.abs() <= 14 * 60);
if out.offset_minutes.is_none() {
out = Self::try_from(t.naive_utc())?.in_utc();
}
Ok(out)
}
}
impl TryFrom<IsoDateTime> for chrono::DateTime<chrono::FixedOffset> {
type Error = ConversionError;
fn try_from(t: IsoDateTime) -> Result<Self, Self::Error> {
let minutes = t.offset_minutes().ok_or(ConversionError::NoOffset)?;
let offset = chrono::FixedOffset::east_opt(i32::from(minutes) * 60)
.ok_or(ConversionError::OutOfRange)?;
let naive =
chrono::NaiveDateTime::try_from(t).map_err(|_| ConversionError::OutOfRange)?;
naive
.and_local_timezone(offset)
.single()
.ok_or(ConversionError::OutOfRange)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn component_dates_enforce_the_same_year_range_as_parsing() {
for year in 0..=u16::MAX {
let date = IsoDate::new(year, 1, 1);
if (1..=9999).contains(&year) {
let date = date.unwrap();
assert!((IsoDate::MIN..=IsoDate::MAX).contains(&date));
assert_eq!(IsoDate::parse(&date.to_string()), Ok(date));
assert_eq!(IsoDate::from_epoch_days(date.epoch_days()), Ok(date));
} else {
assert_eq!(
date,
Err(DateError::YearOutOfRange {
year: i64::from(year)
})
);
}
}
}
#[cfg(feature = "time")]
#[test]
fn time_timestamps_convert_in_both_directions() {
let bare: IsoDateTime = "2026-07-20T13:00:00".parse().unwrap();
let primitive = time::PrimitiveDateTime::try_from(bare).unwrap();
assert_eq!(IsoDateTime::try_from(primitive).unwrap(), bare);
let berlin: IsoDateTime = "2026-07-20T13:00:00+02:00".parse().unwrap();
let offset = time::OffsetDateTime::try_from(berlin).unwrap();
assert_eq!(offset.unix_timestamp(), berlin.unix_seconds().unwrap());
assert_eq!(IsoDateTime::try_from(offset).unwrap(), berlin);
assert_eq!(
time::OffsetDateTime::try_from(bare),
Err(ConversionError::NoOffset)
);
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_timestamps_convert_in_both_directions() {
let bare: IsoDateTime = "2026-07-20T13:00:00".parse().unwrap();
let naive = chrono::NaiveDateTime::try_from(bare).unwrap();
assert_eq!(IsoDateTime::try_from(naive).unwrap(), bare);
let berlin: IsoDateTime = "2026-07-20T13:00:00+02:00".parse().unwrap();
let dt = chrono::DateTime::<chrono::FixedOffset>::try_from(berlin).unwrap();
assert_eq!(dt.timestamp(), berlin.unix_seconds().unwrap());
assert_eq!(IsoDateTime::try_from(dt).unwrap(), berlin);
assert_eq!(
chrono::DateTime::<chrono::FixedOffset>::try_from(bare),
Err(ConversionError::NoOffset)
);
let utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
assert_eq!(
IsoDateTime::try_from(utc).unwrap().to_string(),
"2026-07-20T13:00:00Z"
);
}
#[cfg(feature = "time")]
#[test]
fn time_subminute_offsets_preserve_the_instant_by_rendering_at_utc() {
let local =
time::PrimitiveDateTime::try_from(IsoDateTime::parse("2026-01-01T00:00:00").unwrap())
.unwrap();
for seconds in [
-50_460, -50_401, -50_400, -3631, -1, 0, 1, 3631, 50_400, 50_401, 50_460,
] {
let external =
local.assume_offset(time::UtcOffset::from_whole_seconds(seconds).unwrap());
let converted = IsoDateTime::try_from(external).unwrap();
assert_eq!(
converted.unix_seconds(),
Some(external.unix_timestamp()),
"offset seconds {seconds}"
);
let expected = if seconds % 60 == 0 && seconds.abs() <= 50_400 {
i16::try_from(seconds / 60).unwrap()
} else {
0
};
assert_eq!(converted.offset_minutes(), Some(expected));
assert_eq!(IsoDateTime::parse(&converted.to_string()), Ok(converted));
assert_eq!(
time::OffsetDateTime::try_from(converted)
.unwrap()
.unix_timestamp(),
external.unix_timestamp()
);
}
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_subminute_offsets_preserve_the_instant_by_rendering_at_utc() {
let local =
chrono::NaiveDateTime::try_from(IsoDateTime::parse("2026-01-01T00:00:00").unwrap())
.unwrap();
for seconds in [
-50_460, -50_401, -50_400, -3631, -1, 0, 1, 3631, 50_400, 50_401, 50_460,
] {
let external = local
.and_local_timezone(chrono::FixedOffset::east_opt(seconds).unwrap())
.single()
.unwrap();
let converted = IsoDateTime::try_from(external).unwrap();
assert_eq!(
converted.unix_seconds(),
Some(external.timestamp()),
"offset seconds {seconds}"
);
let expected = if seconds % 60 == 0 && seconds.abs() <= 50_400 {
i16::try_from(seconds / 60).unwrap()
} else {
0
};
assert_eq!(converted.offset_minutes(), Some(expected));
assert_eq!(IsoDateTime::parse(&converted.to_string()), Ok(converted));
assert_eq!(
chrono::DateTime::<chrono::FixedOffset>::try_from(converted)
.unwrap()
.timestamp(),
external.timestamp()
);
}
}
#[cfg(feature = "time")]
#[test]
fn time_offset_fallback_refuses_out_of_range_utc_without_panicking() {
for (raw, offsets) in [
("0001-01-01T00:00:00", [1, 50_401, 50_460, 50_400]),
("9999-12-31T23:59:59", [-1, -50_401, -50_460, -50_400]),
] {
let local =
time::PrimitiveDateTime::try_from(IsoDateTime::parse(raw).unwrap()).unwrap();
for seconds in offsets {
let external =
local.assume_offset(time::UtcOffset::from_whole_seconds(seconds).unwrap());
let converted = IsoDateTime::try_from(external);
if seconds.abs() == 50_400 {
let converted = converted.unwrap();
assert_eq!(converted.unix_seconds(), Some(external.unix_timestamp()));
assert_eq!(
converted.offset_minutes(),
Some(i16::try_from(seconds / 60).unwrap())
);
assert_eq!(IsoDateTime::parse(&converted.to_string()), Ok(converted));
} else {
assert_eq!(
converted,
Err(DateError::OutOfRange {
days: external.unix_timestamp().div_euclid(86_400)
})
);
}
}
}
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_offset_fallback_refuses_out_of_range_utc_without_panicking() {
for (raw, offsets, year) in [
("0001-01-01T00:00:00", [1, 50_401, 50_460, 50_400], 0),
(
"9999-12-31T23:59:59",
[-1, -50_401, -50_460, -50_400],
10_000,
),
] {
let local = chrono::NaiveDateTime::try_from(IsoDateTime::parse(raw).unwrap()).unwrap();
for seconds in offsets {
let external = local
.and_local_timezone(chrono::FixedOffset::east_opt(seconds).unwrap())
.single()
.unwrap();
let converted = IsoDateTime::try_from(external);
if seconds.abs() == 50_400 {
let converted = converted.unwrap();
assert_eq!(converted.unix_seconds(), Some(external.timestamp()));
assert_eq!(
converted.offset_minutes(),
Some(i16::try_from(seconds / 60).unwrap())
);
assert_eq!(IsoDateTime::parse(&converted.to_string()), Ok(converted));
} else {
assert_eq!(converted, Err(DateError::YearOutOfRange { year }));
}
}
}
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_calendar_endpoint_offsets_return_errors_without_panicking() {
for (utc, seconds) in [
(chrono::NaiveDateTime::MIN, -1),
(chrono::NaiveDateTime::MAX, 1),
] {
let external = chrono::DateTime::<chrono::FixedOffset>::from_naive_utc_and_offset(
utc,
chrono::FixedOffset::east_opt(seconds).unwrap(),
);
assert_eq!(
IsoDateTime::try_from(external),
Err(DateError::OutOfRange {
days: (external.timestamp() + i64::from(seconds)).div_euclid(86_400)
})
);
}
}
#[test]
fn timestamps_compare_as_instants_not_as_written_fields() {
let utc: IsoDateTime = "2026-07-20T12:00:00Z".parse().unwrap();
let berlin: IsoDateTime = "2026-07-20T13:00:00+02:00".parse().unwrap();
let naive: IsoDateTime = "2026-07-20T12:00:00".parse().unwrap();
assert!(berlin.unix_seconds() < utc.unix_seconds());
assert_eq!(
utc.unix_seconds().unwrap() - berlin.unix_seconds().unwrap(),
3600
);
assert_eq!(naive.unix_seconds(), None);
assert_ne!(utc, berlin);
assert_eq!(
berlin.to_utc().unwrap(),
"2026-07-20T11:00:00Z".parse().unwrap()
);
assert_eq!(naive.to_utc(), None);
assert_eq!(utc.to_utc(), Some(utc));
}
#[test]
fn a_utc_offset_beyond_the_xsd_range_is_not_an_offset() {
assert!("2026-07-20T12:00:00+14:00".parse::<IsoDateTime>().is_ok());
assert_eq!(
"2026-07-20T12:00:00-14:00"
.parse::<IsoDateTime>()
.unwrap()
.offset_minutes(),
Some(-840)
);
for bad in [
"2026-07-20T12:00:00+14:01",
"2026-07-20T12:00:00+15:00",
"2026-07-20T12:00:00+23:59",
] {
assert!(
bad.parse::<IsoDateTime>().is_err(),
"{bad} must be rejected"
);
}
}
#[test]
fn unix_seconds_agrees_with_the_epoch_across_the_calendar() {
for (text, secs) in [
("1970-01-01T00:00:00Z", 0i64),
("1970-01-01T00:00:01Z", 1),
("1969-12-31T23:59:59Z", -1),
("2026-07-20T12:34:56Z", 1_784_550_896),
] {
let t: IsoDateTime = text.parse().unwrap();
assert_eq!(t.unix_seconds(), Some(secs), "{text}");
assert_eq!(t.to_utc().unwrap().to_string(), text, "{text} round trip");
}
}
#[test]
fn parses_and_renders_the_iso_form() {
let d = IsoDate::parse("2026-07-20").unwrap();
assert_eq!((d.year(), d.month(), d.day()), (2026, 7, 20));
assert_eq!(d.to_string(), "2026-07-20");
}
#[test]
fn rejects_everything_that_is_not_a_bare_iso_date() {
for bad in [
"2026-7-20",
"20260720",
"2026/07/20",
"26-07-20",
"",
"not-a-date",
"2026-07-20T00:00:00",
"2026-07-20 ",
" 2026-07-20",
] {
assert!(
matches!(IsoDate::parse(bad), Err(DateError::Malformed { .. })),
"{bad:?} must be malformed"
);
}
}
#[test]
fn rejects_impossible_calendar_days() {
for (bad, why) in [
("2026-02-30", "February has 28 days in 2026"),
("2023-02-29", "2023 is not a leap year"),
("1900-02-29", "1900 is a century non-leap year"),
("2026-13-01", "there is no month 13"),
("2026-00-10", "there is no month 0"),
("2026-07-00", "there is no day 0"),
("2026-07-32", "July has 31 days"),
] {
assert!(
matches!(IsoDate::parse(bad), Err(DateError::NotACalendarDate { .. })),
"{bad:?} must be rejected: {why}"
);
}
assert!(IsoDate::parse("2000-02-29").is_ok());
assert!(IsoDate::parse("2024-02-29").is_ok());
}
#[test]
fn the_date_part_of_a_timestamp_parses_where_a_strict_date_would_not() {
let d = IsoDate::new(2026, 7, 20).unwrap();
for raw in [
"2026-07-20",
"2026-07-20T00:00:00",
"2026-07-20T12:30:00.123Z",
"2026-07-20+02:00",
] {
assert_eq!(IsoDate::parse_date_part(raw), Ok(d), "{raw}");
}
assert!(IsoDate::parse_date_part("2026-02-30T00:00:00").is_err());
assert!(IsoDate::parse_date_part("20.07.2026").is_err());
for bad in ["2026-07-2€", "2026-07-€0T00", "ü"] {
assert!(IsoDate::parse_date_part(bad).is_err(), "{bad:?}");
}
assert!(IsoDate::parse("2026-07-20T00:00:00").is_err());
}
#[test]
fn year_zero_is_rejected() {
assert!(matches!(
IsoDate::parse("0000-01-01"),
Err(DateError::YearOutOfRange { year: 0 })
));
assert!(IsoDate::parse("0001-01-01").is_ok());
}
#[test]
fn epoch_days_round_trip_across_the_whole_range() {
for date in [
IsoDate::MIN,
IsoDate::new(1969, 12, 31).unwrap(),
IsoDate::new(1970, 1, 1).unwrap(),
IsoDate::new(2000, 2, 29).unwrap(),
IsoDate::new(2026, 7, 20).unwrap(),
IsoDate::MAX,
] {
let days = date.epoch_days();
assert_eq!(IsoDate::from_epoch_days(days).unwrap(), date, "{date}");
}
assert_eq!(IsoDate::new(1970, 1, 1).unwrap().epoch_days(), 0);
assert_eq!(IsoDate::new(1969, 12, 31).unwrap().epoch_days(), -1);
}
#[test]
fn epoch_days_are_dense_and_monotonic_over_a_leap_year() {
let mut d = IsoDate::new(2024, 1, 1).unwrap();
let start = d.epoch_days();
let mut count = 0i64;
while d < IsoDate::new(2025, 1, 1).unwrap() {
assert_eq!(d.epoch_days(), start + count);
d = d.plus_days(1).unwrap();
count += 1;
}
assert_eq!(count, 366, "2024 is a leap year");
}
#[test]
fn arithmetic_crosses_month_and_year_boundaries() {
let d = IsoDate::new(2026, 2, 26).unwrap();
assert_eq!(d.plus_days(3).unwrap().to_string(), "2026-03-01");
assert_eq!(d.plus_days(-57).unwrap().to_string(), "2025-12-31");
assert_eq!(
IsoDate::new(2024, 2, 28).unwrap().plus_days(1).unwrap(),
IsoDate::new(2024, 2, 29).unwrap()
);
}
#[test]
fn arithmetic_past_the_representable_range_errors_rather_than_wrapping() {
assert!(matches!(
IsoDate::MAX.plus_days(1),
Err(DateError::OutOfRange { .. })
));
assert!(matches!(
IsoDate::MIN.plus_days(-1),
Err(DateError::OutOfRange { .. })
));
assert!(matches!(
IsoDate::MAX.plus_days(i64::MAX),
Err(DateError::OutOfRange { .. })
));
assert!(matches!(
IsoDate::MIN.plus_days(i64::MIN),
Err(DateError::OutOfRange { .. })
));
assert!(matches!(
IsoDate::from_epoch_days(i64::MAX),
Err(DateError::OutOfRange { .. })
));
assert!(matches!(
IsoDate::from_epoch_days(i64::MIN),
Err(DateError::OutOfRange { .. })
));
}
#[test]
fn ordering_is_chronological() {
let mut dates = [
IsoDate::new(2026, 1, 31).unwrap(),
IsoDate::new(2025, 12, 1).unwrap(),
IsoDate::new(2026, 1, 2).unwrap(),
];
dates.sort_unstable();
assert_eq!(
dates.iter().map(ToString::to_string).collect::<Vec<_>>(),
["2025-12-01", "2026-01-02", "2026-01-31"]
);
}
#[test]
fn today_is_a_real_date() {
let today = IsoDate::today();
assert!(today > IsoDate::new(2020, 1, 1).unwrap());
assert_eq!(IsoDate::parse(&today.to_string()).unwrap(), today);
}
#[test]
fn timestamps_parse_render_and_keep_their_offset() {
for (raw, rendered) in [
("2026-07-20T12:30:00", "2026-07-20T12:30:00"),
("2026-07-20T12:30:00Z", "2026-07-20T12:30:00Z"),
("2026-07-20T12:30:00.123", "2026-07-20T12:30:00"),
("2026-07-20T12:30:00.123456Z", "2026-07-20T12:30:00Z"),
("2026-07-20T12:30:00+02:00", "2026-07-20T12:30:00+02:00"),
("2026-07-20T12:30:00-05:30", "2026-07-20T12:30:00-05:30"),
] {
assert_eq!(
IsoDateTime::parse(raw).unwrap().to_string(),
rendered,
"{raw}"
);
}
}
#[test]
fn malformed_timestamps_are_rejected() {
for bad in [
"2026-07-20",
"2026-07-20T12:30",
"2026-07-20 12:30:00",
"2026-07-20T25:00:00",
"2026-07-20T12:60:00",
"2026-02-30T12:00:00",
"",
] {
assert!(IsoDateTime::parse(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn a_multibyte_time_part_is_rejected_rather_than_panicking() {
for bad in [
"2026-07-20T€€a",
"2026-07-20T€€",
"2026-07-20T12:30:0€",
"2026-07-20T€€€€€€€",
"2026-07-20T12:30:00+0€:00",
] {
assert!(IsoDateTime::parse(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn now_has_no_offset_and_in_utc_adds_one() {
let now = IsoDateTime::now();
assert_eq!(now.offset_minutes(), None);
assert!(!now.to_string().ends_with('Z'));
assert!(now.in_utc().to_string().ends_with('Z'));
assert_eq!(IsoDateTime::parse(&now.to_string()).unwrap(), now);
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips_through_the_iso_string() {
let d = IsoDate::new(2026, 7, 20).unwrap();
let json = serde_json::to_string(&d).unwrap();
assert_eq!(json, "\"2026-07-20\"");
assert_eq!(serde_json::from_str::<IsoDate>(&json).unwrap(), d);
assert!(serde_json::from_str::<IsoDate>("\"2026-02-30\"").is_err());
let t = IsoDateTime::parse("2026-07-20T12:30:00Z").unwrap();
let json = serde_json::to_string(&t).unwrap();
assert_eq!(json, "\"2026-07-20T12:30:00Z\"");
assert_eq!(serde_json::from_str::<IsoDateTime>(&json).unwrap(), t);
assert_eq!(
serde_json::from_reader::<_, IsoDate>(br#""2026-07-20""#.as_slice()).unwrap(),
d
);
}
#[cfg(feature = "time")]
#[test]
fn time_types_convert_both_ways() {
let d = IsoDate::new(2026, 7, 20).unwrap();
let t: time::Date = d.try_into().unwrap();
assert_eq!(t.year(), 2026);
assert_eq!(IsoDate::try_from(t).unwrap(), d);
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_conversions_cannot_bypass_the_supported_year_range() {
for year in [-1, 0, 10_000, 65_535, 65_536] {
let date = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
let expected = DateError::YearOutOfRange {
year: i64::from(year),
};
assert_eq!(IsoDate::try_from(date), Err(expected.clone()));
assert_eq!(
IsoDateTime::try_from(date.and_hms_opt(0, 0, 0).unwrap()),
Err(expected)
);
}
for date in [IsoDate::MIN, IsoDate::MAX] {
let external = chrono::NaiveDate::try_from(date).unwrap();
assert_eq!(IsoDate::try_from(external), Ok(date));
let timestamp = IsoDateTime::try_from(external.and_hms_opt(0, 0, 0).unwrap()).unwrap();
assert_eq!(timestamp.date(), date);
}
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_types_convert_both_ways() {
let d = IsoDate::new(2026, 7, 20).unwrap();
let c: chrono::NaiveDate = d.try_into().unwrap();
assert_eq!(c.to_string(), "2026-07-20");
assert_eq!(IsoDate::try_from(c).unwrap(), d);
}
}