use crate::error::ParseError;
use core::cmp::Ordering;
use core::fmt;
use core::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DatePrecision {
Year,
Month,
Day,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TimePrecision {
Hour,
Minute,
Second,
Fraction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Offset {
minutes: i32,
zulu: bool,
}
impl Offset {
pub const UTC: Self = Self {
minutes: 0,
zulu: true,
};
#[must_use]
pub fn minutes(&self) -> i32 {
self.minutes
}
}
impl fmt::Display for Offset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.zulu {
return f.write_str("Z");
}
let sign = if self.minutes < 0 { '-' } else { '+' };
let abs = self.minutes.abs();
write!(f, "{sign}{:02}:{:02}", abs / 60, abs % 60)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Date {
year: i32,
month: Option<u8>,
day: Option<u8>,
text: String,
}
impl Date {
#[must_use]
pub fn year(&self) -> i32 {
self.year
}
#[must_use]
pub fn month(&self) -> Option<u8> {
self.month
}
#[must_use]
pub fn day(&self) -> Option<u8> {
self.day
}
#[must_use]
pub fn precision(&self) -> DatePrecision {
match (self.month, self.day) {
(None, _) => DatePrecision::Year,
(Some(_), None) => DatePrecision::Month,
(Some(_), Some(_)) => DatePrecision::Day,
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
fn key(&self) -> (i32, u8, u8) {
(self.year, self.month.unwrap_or(0), self.day.unwrap_or(0))
}
#[must_use]
pub fn semantic_cmp(&self, other: &Self) -> Option<Ordering> {
let common = self.precision().min(other.precision());
let (a, b) = (self.key(), other.key());
let ord = match common {
DatePrecision::Year => a.0.cmp(&b.0),
DatePrecision::Month => (a.0, a.1).cmp(&(b.0, b.1)),
DatePrecision::Day => a.cmp(&b),
};
match ord {
Ordering::Equal if self.precision() != other.precision() => None,
other_ord => Some(other_ord),
}
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl FromStr for Date {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('-').collect();
let bad = |reason| ParseError::new("ISO8601 date", reason, s);
let year_text = parts.first().copied().unwrap_or_default();
if year_text.len() != 4 || !year_text.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad("year is not four digits"));
}
let year: i32 = year_text.parse().map_err(|_| bad("year is not a number"))?;
let two = |text: &str, what| -> Result<u8, ParseError> {
if text.len() != 2 || !text.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad(what));
}
text.parse().map_err(|_| bad(what))
};
let (month, day) = match parts.as_slice() {
[_] => (None, None),
[_, m] => (Some(two(m, "month is not two digits")?), None),
[_, m, d] => (
Some(two(m, "month is not two digits")?),
Some(two(d, "day is not two digits")?),
),
_ => return Err(bad("too many `-`-separated components")),
};
if month.is_some_and(|m| !(1..=12).contains(&m)) {
return Err(bad("month is out of range"));
}
if let (Some(m), Some(d)) = (month, day)
&& (d < 1 || d > days_in_month(year, m))
{
return Err(bad("day is out of range for the month"));
}
Ok(Self {
year,
month,
day,
text: s.to_owned(),
})
}
}
fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
let y = i64::from(year) - i64::from(month <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = i64::from(month);
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub(crate) fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
2 => 28,
_ => 0,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Time {
hour: u8,
minute: Option<u8>,
second: Option<u8>,
fraction: Option<String>,
offset: Option<Offset>,
text: String,
}
impl Time {
#[must_use]
pub fn hour(&self) -> u8 {
self.hour
}
#[must_use]
pub fn minute(&self) -> Option<u8> {
self.minute
}
#[must_use]
pub fn second(&self) -> Option<u8> {
self.second
}
#[must_use]
pub fn fraction(&self) -> Option<&str> {
self.fraction.as_deref()
}
#[must_use]
pub fn offset(&self) -> Option<Offset> {
self.offset
}
#[must_use]
pub fn precision(&self) -> TimePrecision {
match (self.minute, self.second, &self.fraction) {
(None, _, _) => TimePrecision::Hour,
(Some(_), None, _) => TimePrecision::Minute,
(Some(_), Some(_), None) => TimePrecision::Second,
(Some(_), Some(_), Some(_)) => TimePrecision::Fraction,
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
fn millis_local(&self) -> i64 {
let frac_ms = self.fraction.as_deref().map_or(0, |f| {
let mut digits: String = f.chars().take(3).collect();
while digits.len() < 3 {
digits.push('0');
}
digits.parse::<i64>().unwrap_or(0)
});
i64::from(self.hour) * 3_600_000
+ i64::from(self.minute.unwrap_or(0)) * 60_000
+ i64::from(self.second.unwrap_or(0)) * 1_000
+ frac_ms
}
}
impl Time {
#[must_use]
pub fn semantic_cmp(&self, other: &Self) -> Option<Ordering> {
match (self.offset, other.offset) {
(Some(_), None) | (None, Some(_)) => return None,
_ => {}
}
let a = self.millis_local() - i64::from(self.offset.map_or(0, |o| o.minutes)) * 60_000;
let b = other.millis_local() - i64::from(other.offset.map_or(0, |o| o.minutes)) * 60_000;
match a.cmp(&b) {
Ordering::Equal if self.precision() != other.precision() => None,
ord => Some(ord),
}
}
}
impl fmt::Display for Time {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl FromStr for Time {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bad = |reason| ParseError::new("ISO8601 time", reason, s);
let (body, offset) = split_offset(s).ok_or_else(|| bad("malformed UTC offset"))?;
let (body, fraction) = match body.split_once('.') {
None => (body, None),
Some((head, frac)) => {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad("fractional second is not a number"));
}
(head, Some(frac.to_owned()))
}
};
let parts: Vec<&str> = body.split(':').collect();
let two = |text: &str, what| -> Result<u8, ParseError> {
if text.len() != 2 || !text.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad(what));
}
text.parse().map_err(|_| bad(what))
};
let (hour, minute, second) = match parts.as_slice() {
[h] => (two(h, "hour is not two digits")?, None, None),
[h, m] => (
two(h, "hour is not two digits")?,
Some(two(m, "minute is not two digits")?),
None,
),
[h, m, sec] => (
two(h, "hour is not two digits")?,
Some(two(m, "minute is not two digits")?),
Some(two(sec, "second is not two digits")?),
),
_ => return Err(bad("too many `:`-separated components")),
};
if fraction.is_some() && second.is_none() {
return Err(bad("fractional part without a second"));
}
if hour > 23 {
return Err(bad("hour is out of range"));
}
if minute.is_some_and(|m| m > 59) {
return Err(bad("minute is out of range"));
}
if second.is_some_and(|sec| sec > 60) {
return Err(bad("second is out of range"));
}
Ok(Self {
hour,
minute,
second,
fraction,
offset,
text: s.to_owned(),
})
}
}
fn split_offset(s: &str) -> Option<(&str, Option<Offset>)> {
if let Some(head) = s.strip_suffix('Z') {
return Some((head, Some(Offset::UTC)));
}
let Some((idx, _)) = s
.char_indices()
.rev()
.take(6)
.find(|(_, c)| *c == '+' || *c == '-')
else {
return Some((s, None));
};
if idx == 0 {
return None;
}
let (head, tail) = s.split_at(idx);
let sign = if tail.starts_with('-') { -1 } else { 1 };
let digits = &tail[1..];
if !digits.bytes().all(|b| b.is_ascii_digit() || b == b':') {
return None;
}
let (hh, mm) = match digits.split_once(':') {
Some((hh, mm)) => (hh, mm),
None if digits.len() == 4 => digits.split_at(2),
None if digits.len() == 2 => (digits, "00"),
None => return None,
};
if hh.len() != 2 || mm.len() != 2 {
return None;
}
let hh: i32 = hh.parse().ok()?;
let mm: i32 = mm.parse().ok()?;
if hh > 14 || mm > 59 {
return None;
}
Some((
head,
Some(Offset {
minutes: sign * (hh * 60 + mm),
zulu: false,
}),
))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DateTime {
date: Date,
time: Option<Time>,
text: String,
}
impl DateTime {
#[must_use]
pub fn date(&self) -> &Date {
&self.date
}
#[must_use]
pub fn time(&self) -> Option<&Time> {
self.time.as_ref()
}
#[must_use]
pub fn offset(&self) -> Option<Offset> {
self.time.as_ref().and_then(Time::offset)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
}
impl DateTime {
#[must_use]
pub fn diff_seconds(&self, other: &Self) -> Option<i64> {
match (self.offset(), other.offset()) {
(Some(_), None) | (None, Some(_)) => return None,
_ => {}
}
Some(self.epoch_seconds()? - other.epoch_seconds()?)
}
fn epoch_seconds(&self) -> Option<i64> {
let (month, day) = (self.date.month()?, self.date.day()?);
let time = self.time.as_ref()?;
let days = days_from_civil(self.date.year(), month, day);
let seconds = i64::from(time.hour()) * 3600
+ i64::from(time.minute().unwrap_or(0)) * 60
+ i64::from(time.second().unwrap_or(0));
let offset = i64::from(time.offset().map_or(0, |o| o.minutes())) * 60;
Some(days * 86_400 + seconds - offset)
}
}
impl DateTime {
#[must_use]
pub fn semantic_cmp(&self, other: &Self) -> Option<Ordering> {
match self.date.semantic_cmp(&other.date)? {
Ordering::Equal => {}
ord => return Some(ord),
}
match (&self.time, &other.time) {
(None, None) => Some(Ordering::Equal),
(None, Some(_)) | (Some(_), None) => None,
(Some(a), Some(b)) => a.semantic_cmp(b),
}
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl FromStr for DateTime {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (date_text, time_text) = match s.split_once('T') {
None => (s, None),
Some((d, t)) => {
if t.is_empty() {
return Err(ParseError::new(
"ISO8601 date-time",
"`T` with no time after it",
s,
));
}
(d, Some(t))
}
};
let date: Date = date_text
.parse()
.map_err(|e: ParseError| ParseError::new("ISO8601 date-time", e.reason, s))?;
let time = time_text
.map(str::parse::<Time>)
.transpose()
.map_err(|e: ParseError| ParseError::new("ISO8601 date-time", e.reason, s))?;
if time.is_some() && date.precision() != DatePrecision::Day {
return Err(ParseError::new(
"ISO8601 date-time",
"a time requires a full date",
s,
));
}
Ok(Self {
date,
time,
text: s.to_owned(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Duration {
negative: bool,
years: u32,
months: u32,
weeks: u32,
days: u32,
hours: u32,
minutes: u32,
seconds: Option<String>,
text: String,
}
impl Duration {
#[must_use]
pub fn is_negative(&self) -> bool {
self.negative
}
#[must_use]
pub fn years(&self) -> u32 {
self.years
}
#[must_use]
pub fn months(&self) -> u32 {
self.months
}
#[must_use]
pub fn weeks(&self) -> u32 {
self.weeks
}
#[must_use]
pub fn days(&self) -> u32 {
self.days
}
#[must_use]
pub fn hours(&self) -> u32 {
self.hours
}
#[must_use]
pub fn minutes(&self) -> u32 {
self.minutes
}
#[must_use]
pub fn seconds(&self) -> Option<&str> {
self.seconds.as_deref()
}
#[must_use]
pub fn is_zero(&self) -> bool {
self.years == 0
&& self.months == 0
&& self.weeks == 0
&& self.days == 0
&& self.hours == 0
&& self.minutes == 0
&& self
.seconds
.as_deref()
.is_none_or(|s| s.parse::<f64>().is_ok_and(|v| v == 0.0))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub fn approx_seconds(&self) -> f64 {
let secs = f64::from(self.years) * 365.2425 * 86_400.0
+ f64::from(self.months) * 30.436_875 * 86_400.0
+ f64::from(self.weeks) * 7.0 * 86_400.0
+ f64::from(self.days) * 86_400.0
+ f64::from(self.hours) * 3_600.0
+ f64::from(self.minutes) * 60.0
+ self
.seconds
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
if self.negative { -secs } else { secs }
}
}
impl fmt::Display for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl Duration {
#[must_use]
pub fn semantic_cmp(&self, other: &Self) -> Option<Ordering> {
let (a, b) = (self.approx_seconds(), other.approx_seconds());
let calendarish = |d: &Self| d.years > 0 || d.months > 0;
match a.partial_cmp(&b)? {
Ordering::Equal
if (calendarish(self) || calendarish(other)) && self.text != other.text =>
{
None
}
ord => Some(ord),
}
}
}
impl FromStr for Duration {
type Err = ParseError;
#[allow(clippy::too_many_lines)]
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bad = |reason| ParseError::new("ISO8601 duration", reason, s);
let (negative, rest) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s),
};
let Some(rest) = rest.strip_prefix('P') else {
return Err(bad("does not start with `P`"));
};
let (date_part, time_part) = match rest.split_once('T') {
None => (rest, ""),
Some((d, t)) => {
if t.is_empty() {
return Err(bad("`T` with no time components after it"));
}
(d, t)
}
};
let mut out = Self {
negative,
years: 0,
months: 0,
weeks: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: None,
text: s.to_owned(),
};
let mut any = false;
let mut scan =
|part: &str, designators: &[char], is_time: bool| -> Result<(), ParseError> {
let mut next_allowed = 0usize;
let mut number = String::new();
for ch in part.chars() {
if ch.is_ascii_digit() || (ch == '.' && is_time) {
number.push(ch);
continue;
}
let Some(pos) = designators.iter().position(|d| *d == ch) else {
return Err(bad("unknown duration designator"));
};
if pos < next_allowed {
return Err(bad("duration designators are out of order or repeated"));
}
if number.is_empty() {
return Err(bad("designator with no number"));
}
next_allowed = pos + 1;
any = true;
if is_time && ch == 'S' {
if number.parse::<f64>().is_err() {
return Err(bad("seconds is not a number"));
}
out.seconds = Some(core::mem::take(&mut number));
} else {
if number.contains('.') {
return Err(bad("only seconds may be fractional"));
}
let value: u32 = number
.parse()
.map_err(|_| bad("component does not fit in u32"))?;
number.clear();
match ch {
'Y' => out.years = value,
'M' if is_time => out.minutes = value,
'M' => out.months = value,
'W' => out.weeks = value,
'D' => out.days = value,
'H' => out.hours = value,
_ => return Err(bad("unknown duration designator")),
}
}
}
if number.is_empty() {
Ok(())
} else {
Err(bad("number with no designator"))
}
};
scan(date_part, &['Y', 'M', 'W', 'D'], false)?;
scan(time_part, &['H', 'M', 'S'], true)?;
if !any {
return Err(bad("no components"));
}
Ok(out)
}
}
crate::impl_string_serde!(Date, "ISO8601 date");
crate::impl_string_serde!(Time, "ISO8601 time");
crate::impl_string_serde!(DateTime, "ISO8601 date-time");
crate::impl_string_serde!(Duration, "ISO8601 duration");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_dates_keep_their_precision_and_text() {
for text in ["2024", "2024-05", "2024-05-17"] {
let d: Date = text.parse().unwrap();
assert_eq!(d.to_string(), text);
}
assert_eq!(
"2024".parse::<Date>().unwrap().precision(),
DatePrecision::Year
);
assert_eq!(
"2024-05".parse::<Date>().unwrap().precision(),
DatePrecision::Month
);
}
#[test]
fn a_coarser_date_is_not_ordered_inside_its_own_range() {
let may: Date = "2024-05".parse().unwrap();
let may_17: Date = "2024-05-17".parse().unwrap();
assert_eq!(may.semantic_cmp(&may_17), None);
let april: Date = "2024-04".parse().unwrap();
assert_eq!(april.semantic_cmp(&may_17), Some(Ordering::Less));
}
#[test]
fn leap_day_validity_follows_the_gregorian_rule() {
assert!("2024-02-29".parse::<Date>().is_ok());
assert!("2023-02-29".parse::<Date>().is_err());
assert!("2000-02-29".parse::<Date>().is_ok()); assert!("1900-02-29".parse::<Date>().is_err()); }
#[test]
fn offsets_normalise_before_comparison() {
let london: Time = "12:00:00+01:00".parse().unwrap();
let utc: Time = "11:00:00Z".parse().unwrap();
assert_eq!(london.semantic_cmp(&utc), Some(Ordering::Equal));
}
#[test]
fn a_local_time_is_not_comparable_with_an_anchored_one() {
let local: Time = "11:00:00".parse().unwrap();
let utc: Time = "11:00:00Z".parse().unwrap();
assert_eq!(local.semantic_cmp(&utc), None);
}
#[test]
fn date_time_rejects_a_time_on_a_partial_date() {
assert!("2024-05T10:00:00".parse::<DateTime>().is_err());
assert!("2024-05-17T10:00:00Z".parse::<DateTime>().is_ok());
}
#[test]
fn differencing_two_instants_crosses_months_and_offsets() {
let dt = |t: &str| t.parse::<DateTime>().unwrap();
assert_eq!(
dt("2026-07-31T17:30:00Z").diff_seconds(&dt("2026-07-31T09:00:00Z")),
Some(30_600)
);
assert_eq!(
dt("2024-03-01T00:00:00Z").diff_seconds(&dt("2024-02-28T00:00:00Z")),
Some(172_800)
);
assert_eq!(
dt("2023-03-01T00:00:00Z").diff_seconds(&dt("2023-02-28T00:00:00Z")),
Some(86_400)
);
assert_eq!(
dt("1900-03-01T00:00:00Z").diff_seconds(&dt("1900-02-28T00:00:00Z")),
Some(86_400)
);
assert_eq!(
dt("2026-07-31T10:00:00+01:00").diff_seconds(&dt("2026-07-31T09:00:00Z")),
Some(0)
);
assert_eq!(
dt("2026-07-31T09:00:00").diff_seconds(&dt("2026-07-31T09:00:00Z")),
None
);
assert_eq!(dt("2026-07-31").diff_seconds(&dt("2026-07-30")), None);
}
#[test]
fn durations_round_trip_and_reject_disorder() {
for text in ["P1Y", "P1Y2M3W4DT5H6M7.5S", "-PT30M", "PT0S"] {
assert_eq!(text.parse::<Duration>().unwrap().to_string(), text);
}
for text in ["P1M1Y", "P1D1D", "1Y", "P", "PT", "P1.5D", "PY"] {
assert!(text.parse::<Duration>().is_err(), "accepted {text}");
}
}
#[test]
fn duration_minute_and_month_are_disambiguated_by_the_t() {
let month: Duration = "P1M".parse().unwrap();
assert_eq!((month.months(), month.minutes()), (1, 0));
let minute: Duration = "PT1M".parse().unwrap();
assert_eq!((minute.months(), minute.minutes()), (0, 1));
}
#[test]
fn calendar_durations_are_not_ordered_against_equivalent_day_counts() {
let twelve_months: Duration = "P12M".parse().unwrap();
let one_year: Duration = "P1Y".parse().unwrap();
assert_eq!(twelve_months.semantic_cmp(&one_year), None);
let one_month: Duration = "P1M".parse().unwrap();
let thirty_days: Duration = "P30D".parse().unwrap();
assert_eq!(one_month.semantic_cmp(&thirty_days), Some(Ordering::Greater));
}
#[test]
fn an_offset_prints_back_with_the_sign_it_arrived_with() {
let offset_of = |text: &str| {
text.parse::<Time>()
.unwrap_or_else(|e| panic!("{text}: {e}"))
.offset()
.unwrap_or_else(|| panic!("{text} carries no offset"))
};
for (text, minutes, rendered) in [
("12:00:00-05:00", -300, "-05:00"),
("12:00:00+05:30", 330, "+05:30"),
("12:00:00-00:30", -30, "-00:30"),
("12:00:00+00:00", 0, "+00:00"),
("12:00:00Z", 0, "Z"),
] {
let offset = offset_of(text);
assert_eq!(offset.minutes(), minutes, "{text}");
assert_eq!(offset.to_string(), rendered, "{text}");
assert_eq!(text.parse::<Time>().unwrap().as_str(), text);
}
}
#[test]
fn days_from_the_civil_epoch_match_an_independent_calendar() {
for (year, month, day, days) in [
(1970, 1, 1, 0),
(1969, 12, 31, -1),
(2000, 2, 29, 11_016),
(1900, 3, 1, -25_508),
(2026, 8, 3, 20_668),
(1582, 10, 15, -141_427),
(1600, 2, 29, -135_081),
(2400, 2, 29, 157_113),
(1, 1, 1, -719_162),
(1, 3, 1, -719_103),
(0, 1, 1, -719_528),
(0, 2, 29, -719_469),
(0, 3, 1, -719_468),
(0, 12, 31, -719_163),
] {
assert_eq!(
days_from_civil(year, month, day),
days,
"{year:04}-{month:02}-{day:02}"
);
}
let a: DateTime = "2026-08-03T00:00:00Z".parse().unwrap();
let epoch: DateTime = "1970-01-01T00:00:00Z".parse().unwrap();
assert_eq!(a.diff_seconds(&epoch), Some(20_668 * 86_400));
}
#[test]
fn a_date_component_must_be_the_right_length_and_all_digits() {
for text in [
"20x6-01-01", "202-01-01", "20266-01-01",
"2026-ab-01", "2026-1-01", "2026-011-01",
"2026-01-ab",
"2026-01-1",
] {
assert!(
text.parse::<Date>().is_err(),
"{text} was accepted as a date"
);
}
for text in ["2026", "2026-01", "2026-01-31"] {
assert_eq!(text.parse::<Date>().unwrap().as_str(), text);
}
}
#[test]
fn every_offset_form_is_read_and_every_malformed_one_refused() {
let minutes = |text: &str| {
text.parse::<Time>()
.unwrap_or_else(|e| panic!("{text}: {e}"))
.offset()
.map(|o| o.minutes())
};
assert_eq!(minutes("12:00:00+05:30"), Some(330));
assert_eq!(minutes("12:00:00+0530"), Some(330));
assert_eq!(minutes("12:00:00+05"), Some(300));
assert_eq!(minutes("12:00:00-05:30"), Some(-330));
assert_eq!(minutes("12:00:00-0530"), Some(-330));
assert_eq!(minutes("12:00:00-05"), Some(-300));
assert_eq!(minutes("12:00:00"), None);
assert_eq!(minutes("12:00:00+14:00"), Some(840));
assert_eq!(minutes("12:00:00+13:59"), Some(839));
for bad in [
"12:00:00+15:00", "12:00:00+14:60", "12:00:00+99:00",
"12:00:00+5:30", "12:00:00+05:3",
"12:00:00+053", "12:00:00+05300",
"12:00:00+ab:cd",
"12:00:00+\u{1F600}",
] {
assert!(bad.parse::<Time>().is_err(), "{bad} was accepted");
}
}
#[test]
fn a_time_parses_at_each_precision_and_keeps_its_text() {
for (text, hour, minute, second, fraction) in [
("09", 9u8, None, None, None),
("09:30", 9, Some(30u8), None, None),
("09:30:15", 9, Some(30), Some(15u8), None),
("09:30:15.250", 9, Some(30), Some(15), Some("250")),
("23:59:60", 23, Some(59), Some(60), None),
] {
let t: Time = text.parse().unwrap_or_else(|e| panic!("{text}: {e}"));
assert_eq!(t.hour(), hour, "{text}");
assert_eq!(t.minute(), minute, "{text}");
assert_eq!(t.second(), second, "{text}");
assert_eq!(t.fraction(), fraction, "{text}");
assert_eq!(t.as_str(), text);
}
for bad in [
"1x:00", "9:00", "090", "09:0", "09:000", "09:30:1",
"09:30:15.", "09:30:15.x", "09:30:15:00",
] {
assert!(bad.parse::<Time>().is_err(), "{bad} was accepted as a time");
}
}
#[test]
fn times_order_by_the_instant_they_denote() {
let t = |s: &str| s.parse::<Time>().unwrap();
assert_eq!(t("09:00:00").semantic_cmp(&t("10:00:00")), Some(Ordering::Less));
assert_eq!(t("09:00:00").semantic_cmp(&t("09:01:00")), Some(Ordering::Less));
assert_eq!(t("09:00:00").semantic_cmp(&t("09:00:01")), Some(Ordering::Less));
assert_eq!(t("09:00:00.100").semantic_cmp(&t("09:00:00.200")), Some(Ordering::Less));
assert_eq!(t("00:02:00").semantic_cmp(&t("00:00:59")), Some(Ordering::Greater), "a minute is not sixty seconds");
assert_eq!(t("00:00:02").semantic_cmp(&t("00:00:00.500")), Some(Ordering::Greater), "a second is not 1000ms");
assert_eq!(t("01:00:00").semantic_cmp(&t("00:59:59")), Some(Ordering::Greater), "an hour is not sixty minutes");
assert_eq!(t("09:00:00.5").semantic_cmp(&t("09:00:01")), Some(Ordering::Less), "`.5` was read as five seconds");
assert_eq!(t("09:00:00.5").semantic_cmp(&t("09:00:00.499")), Some(Ordering::Greater));
let same = Some(Ordering::Equal);
assert_eq!(t("09:00:00.5").semantic_cmp(&t("09:00:00.500")), same);
assert_eq!(t("09:00:00.5009").semantic_cmp(&t("09:00:00.500")), same);
assert_eq!(t("12:00:00+01:00").semantic_cmp(&t("11:00:00Z")), same);
assert_eq!(t("12:00:00+01:00").semantic_cmp(&t("12:00:00Z")), Some(Ordering::Less));
assert_eq!(t("09:00").semantic_cmp(&t("09:00:00")), None);
assert_eq!(t("12:00:00Z").semantic_cmp(&t("12:00:00")), None);
}
#[test]
fn eq_is_lexical_and_semantic_cmp_is_not_the_same_question() {
let t = |s: &str| s.parse::<Time>().unwrap();
let (utc, plus_one) = (t("11:00:00Z"), t("12:00:00+01:00"));
assert_eq!(utc.semantic_cmp(&plus_one), Some(Ordering::Equal));
assert_ne!(utc, plus_one, "`Eq` is lexical, and these differ");
assert!(matches!(utc.semantic_cmp(&plus_one), Some(Ordering::Equal)));
assert!(!matches!(utc.semantic_cmp(&plus_one), Some(Ordering::Less)));
assert!(!matches!(
utc.semantic_cmp(&plus_one),
Some(Ordering::Greater)
));
assert_ne!(utc, plus_one);
assert_eq!(
t("09:00:00.5").semantic_cmp(&t("09:00:00.50")),
Some(Ordering::Equal)
);
assert_ne!(t("09:00:00.5"), t("09:00:00.50"));
assert_eq!(utc, t("11:00:00Z"));
assert_eq!(utc.semantic_cmp(&t("11:00:00Z")), Some(Ordering::Equal));
}
#[test]
fn a_duration_reports_each_component_and_an_approximate_length() {
let d: Duration = "P1Y2M3W4DT5H6M7.5S".parse().unwrap();
assert!(!d.is_negative());
assert_eq!(d.years(), 1);
assert_eq!(d.months(), 2);
assert_eq!(d.weeks(), 3);
assert_eq!(d.days(), 4);
assert_eq!(d.hours(), 5);
assert_eq!(d.minutes(), 6);
assert_eq!(d.seconds(), Some("7.5"));
assert_eq!(d.as_str(), "P1Y2M3W4DT5H6M7.5S");
let want = 365.2425 * 86_400.0
+ 2.0 * 30.436_875 * 86_400.0
+ 3.0 * 7.0 * 86_400.0
+ 4.0 * 86_400.0
+ 5.0 * 3_600.0
+ 6.0 * 60.0
+ 7.5;
assert!(
(d.approx_seconds() - want).abs() < 1e-6,
"{} != {want}",
d.approx_seconds()
);
for (text, seconds) in [
("P1Y", 365.2425 * 86_400.0),
("P1M", 30.436_875 * 86_400.0),
("P1W", 7.0 * 86_400.0),
("P1D", 86_400.0),
("PT1H", 3_600.0),
("PT1M", 60.0),
("PT1S", 1.0),
("PT0S", 0.0),
] {
let d: Duration = text.parse().unwrap_or_else(|e| panic!("{text}: {e}"));
assert!(
(d.approx_seconds() - seconds).abs() < 1e-6,
"{text}: {} != {seconds}",
d.approx_seconds()
);
}
for (more, less) in [("P2W", "P1W"), ("P2D", "P1D"), ("PT2S", "PT1S")] {
let (a, b): (Duration, Duration) = (more.parse().unwrap(), less.parse().unwrap());
assert!(
a.approx_seconds() > b.approx_seconds(),
"{more} is not longer than {less}"
);
}
let d: Duration = "P1D".parse().unwrap();
assert_eq!(d.seconds(), None);
assert_eq!(d.weeks(), 0);
assert_eq!(d.days(), 1);
}
#[test]
#[allow(clippy::float_cmp)]
fn durations_order_by_length_and_refuse_when_a_calendar_is_needed() {
let d = |s: &str| s.parse::<Duration>().unwrap_or_else(|e| panic!("{s}: {e}"));
assert_eq!(d("PT1H").semantic_cmp(&d("PT2H")), Some(Ordering::Less));
assert_eq!(d("P1D").semantic_cmp(&d("P1W")), Some(Ordering::Less));
assert_eq!(d("PT59S").semantic_cmp(&d("PT1M")), Some(Ordering::Less));
assert_eq!(d("P1D").semantic_cmp(&d("P1D")), Some(Ordering::Equal));
assert_eq!(d("P1M").semantic_cmp(&d("P1M")), Some(Ordering::Equal));
let month = d("P1M");
let same_days = d("PT2629746S"); assert_eq!(
month.approx_seconds(),
same_days.approx_seconds(),
"fixture no longer pins the guard"
);
assert_eq!(month.semantic_cmp(&same_days), None, "P1M vs an equal span");
assert_eq!(same_days.semantic_cmp(&month), None, "and the other way");
let year = d("P1Y");
let year_in_seconds = d("PT31556952S"); assert_eq!(year.approx_seconds(), year_in_seconds.approx_seconds());
assert_eq!(year.semantic_cmp(&year_in_seconds), None);
assert_eq!(
d("P1W").semantic_cmp(&d("P7D")),
Some(Ordering::Equal),
"a week is exactly seven days, with no calendar involved"
);
assert_eq!(d("P1M").semantic_cmp(&d("P2M")), Some(Ordering::Less));
assert_eq!(d("P1M").semantic_cmp(&d("P1D")), Some(Ordering::Greater));
}
#[test]
fn a_negative_duration_is_shorter_than_zero() {
let d = |s: &str| s.parse::<Duration>().unwrap_or_else(|e| panic!("{s}: {e}"));
let minus_day = d("-P1D");
assert!(minus_day.is_negative());
assert!((minus_day.approx_seconds() - -86_400.0).abs() < f64::EPSILON);
assert_eq!(minus_day.as_str(), "-P1D");
assert!(!d("P1D").is_negative());
assert_eq!(minus_day.semantic_cmp(&d("PT0S")), Some(Ordering::Less));
assert_eq!(minus_day.semantic_cmp(&d("P1D")), Some(Ordering::Less));
assert_eq!(d("-P2D").semantic_cmp(&d("-P1D")), Some(Ordering::Less), "more negative is less");
}
#[test]
fn epoch_seconds_adds_each_part_of_the_time_of_day() {
let epoch: DateTime = "1970-01-01T00:00:00Z".parse().unwrap();
for (text, seconds) in [
("1970-01-01T00:00:07Z", 7),
("1970-01-01T00:03:00Z", 180),
("1970-01-01T04:00:00Z", 14_400),
("1970-01-01T04:03:07Z", 14_400 + 180 + 7),
("1970-01-01T04:03:07+01:00", 14_400 + 180 + 7 - 3_600),
] {
let t: DateTime = text.parse().unwrap();
assert_eq!(t.diff_seconds(&epoch), Some(seconds), "{text}");
}
}
}