use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DurationKind {
YearMonth,
DayTime,
}
impl DurationKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
DurationKind::YearMonth => "xs:yearMonthDuration",
DurationKind::DayTime => "xs:dayTimeDuration",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Duration {
kind: DurationKind,
months: i64,
seconds: f64,
}
impl Duration {
#[must_use]
pub const fn kind(&self) -> DurationKind {
self.kind
}
#[must_use]
pub const fn to_months(&self) -> i64 {
self.months
}
#[must_use]
pub const fn to_seconds(&self) -> f64 {
self.seconds
}
#[must_use]
pub const fn from_months(months: i64) -> Self {
Self {
kind: DurationKind::YearMonth,
months,
seconds: 0.0,
}
}
#[must_use]
pub const fn from_seconds(seconds: f64) -> Self {
Self {
kind: DurationKind::DayTime,
months: 0,
seconds,
}
}
#[must_use]
pub fn is_negative(&self) -> bool {
match self.kind {
DurationKind::YearMonth => self.months < 0,
DurationKind::DayTime => self.seconds < 0.0,
}
}
pub fn parse(text: &str, kind: DurationKind) -> Result<Duration, String> {
let trimmed = text.trim();
let reject = || format!("{trimmed:?} is not a valid {} value", kind.as_str());
let (negative, rest) = match trimmed.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, trimmed),
};
let rest = rest.strip_prefix('P').ok_or_else(reject)?;
if rest.is_empty() {
return Err(reject());
}
let (date_part, time_part) = match rest.split_once('T') {
Some((date, time)) => {
if time.is_empty() {
return Err(reject());
}
(date, Some(time))
}
None => (rest, None),
};
let date_fields = parse_duration_fields(date_part, "YMD").ok_or_else(reject)?;
let time_fields = match time_part {
Some(time) => parse_duration_fields(time, "HMS").ok_or_else(reject)?,
None => Vec::new(),
};
if date_fields.is_empty() && time_fields.is_empty() {
return Err(reject());
}
let mut months = 0_i64;
let mut seconds = 0.0_f64;
#[allow(clippy::cast_possible_truncation)] for (value, unit) in &date_fields {
match unit {
'Y' => months += *value as i64 * 12,
'M' => months += *value as i64,
'D' => seconds += value * 86_400.0,
_ => return Err(reject()),
}
}
for (value, unit) in &time_fields {
match unit {
'H' => seconds += value * 3_600.0,
'M' => seconds += value * 60.0,
'S' => seconds += value,
_ => return Err(reject()),
}
}
match kind {
DurationKind::YearMonth if seconds != 0.0 => return Err(reject()),
DurationKind::DayTime if months != 0 => return Err(reject()),
_ => {}
}
let sign = if negative { -1.0 } else { 1.0 };
Ok(match kind {
DurationKind::YearMonth => {
Duration::from_months(if negative { -months } else { months })
}
DurationKind::DayTime => Duration::from_seconds(seconds * sign),
})
}
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn to_lexical(&self) -> String {
let mut out = String::new();
if self.is_negative() {
out.push('-');
}
out.push('P');
match self.kind {
DurationKind::YearMonth => {
let months = self.months.abs();
let (years, months) = (months / 12, months % 12);
if years != 0 {
out.push_str(&format!("{years}Y"));
}
if months != 0 || years == 0 {
out.push_str(&format!("{months}M"));
}
}
DurationKind::DayTime => {
let total = self.seconds.abs();
let days = (total / 86_400.0).floor();
let rest = total - days * 86_400.0;
let hours = (rest / 3_600.0).floor();
let minutes = ((rest % 3_600.0) / 60.0).floor();
let seconds = rest % 60.0;
if days != 0.0 {
out.push_str(&format!("{}D", days as i64));
}
if hours != 0.0 || minutes != 0.0 || seconds != 0.0 {
out.push('T');
if hours != 0.0 {
out.push_str(&format!("{}H", hours as i64));
}
if minutes != 0.0 {
out.push_str(&format!("{}M", minutes as i64));
}
if seconds != 0.0 {
if seconds.fract() == 0.0 {
out.push_str(&format!("{}S", seconds as i64));
} else {
out.push_str(&format!("{seconds}S"));
}
}
} else if days == 0.0 {
out.push_str("T0S");
}
}
}
out
}
}
impl fmt::Display for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_lexical())
}
}
impl PartialEq for Duration {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind && self.months == other.months && self.seconds == other.seconds
}
}
impl PartialOrd for Duration {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.kind != other.kind {
return None;
}
match self.kind {
DurationKind::YearMonth => Some(self.months.cmp(&other.months)),
DurationKind::DayTime => self.seconds.partial_cmp(&other.seconds),
}
}
}
fn parse_duration_fields(text: &str, units: &str) -> Option<Vec<(f64, char)>> {
if text.is_empty() {
return Some(Vec::new());
}
let mut fields = Vec::new();
let mut digits = String::new();
let mut next_unit = 0;
for c in text.chars() {
if c.is_ascii_digit() || c == '.' {
digits.push(c);
continue;
}
let position = units.find(c)?;
if position < next_unit || digits.is_empty() {
return None;
}
next_unit = position + 1;
fields.push((digits.parse().ok()?, c));
digits.clear();
}
if !digits.is_empty() {
return None;
}
Some(fields)
}
#[must_use]
pub fn add_months(temporal: &Temporal, months: i64) -> Temporal {
let total = temporal.year() * 12 + i64::from(temporal.month()) - 1 + months;
let year = total.div_euclid(12);
let month = u32::try_from(total.rem_euclid(12) + 1).unwrap_or(1);
let day = temporal.day().min(days_in_month(year, month));
Temporal {
kind: temporal.kind(),
year,
month,
day,
hour: temporal.hour(),
minute: temporal.minute(),
second: temporal.second(),
offset_minutes: temporal.offset_minutes(),
}
}
#[must_use]
pub fn add_seconds(temporal: &Temporal, seconds: f64) -> Temporal {
let offset = f64::from(temporal.offset_minutes().unwrap_or(0)) * 60.0;
let shifted = from_unix_seconds(temporal.kind(), temporal.to_seconds() + seconds + offset);
Temporal {
offset_minutes: temporal.offset_minutes(),
..shifted
}
}
const TIME_REFERENCE: (i64, u32, u32) = (1972, 12, 31);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TemporalKind {
Date,
DateTime,
Time,
}
impl TemporalKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
TemporalKind::Date => "xs:date",
TemporalKind::DateTime => "xs:dateTime",
TemporalKind::Time => "xs:time",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Temporal {
kind: TemporalKind,
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: f64,
offset_minutes: Option<i32>,
}
impl Temporal {
#[must_use]
pub const fn kind(&self) -> TemporalKind {
self.kind
}
#[must_use]
pub const fn year(&self) -> i64 {
self.year
}
#[must_use]
pub const fn month(&self) -> u32 {
self.month
}
#[must_use]
pub const fn day(&self) -> u32 {
self.day
}
#[must_use]
pub const fn hour(&self) -> u32 {
self.hour
}
#[must_use]
pub const fn minute(&self) -> u32 {
self.minute
}
#[must_use]
pub const fn second(&self) -> f64 {
self.second
}
#[must_use]
pub const fn offset_minutes(&self) -> Option<i32> {
self.offset_minutes
}
#[must_use]
pub fn from_utc(
kind: TemporalKind,
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: f64,
) -> Self {
Self {
kind,
year,
month,
day,
hour,
minute,
second,
offset_minutes: Some(0),
}
}
pub fn parse(text: &str, kind: TemporalKind) -> Result<Temporal, String> {
let trimmed = text.trim();
let reject = || {
format!(
"{trimmed:?} is not a valid {} value",
kind.as_str()
)
};
let (body, offset) = split_timezone(trimmed).ok_or_else(reject)?;
let parsed = match kind {
TemporalKind::Date => parse_date(body).map(|(y, m, d)| (y, m, d, 0, 0, 0.0)),
TemporalKind::Time => parse_time(body)
.map(|(h, mi, s)| (TIME_REFERENCE.0, TIME_REFERENCE.1, TIME_REFERENCE.2, h, mi, s)),
TemporalKind::DateTime => {
let (date, time) = body.split_once('T').ok_or_else(reject)?;
let (year, month, day) = parse_date(date).ok_or_else(reject)?;
let (hour, minute, second) = parse_time(time).ok_or_else(reject)?;
Some((year, month, day, hour, minute, second))
}
};
let (year, month, day, hour, minute, second) = parsed.ok_or_else(reject)?;
if !is_valid_civil(year, month, day) {
return Err(reject());
}
Ok(Temporal {
kind,
year,
month,
day,
hour,
minute,
second,
offset_minutes: offset,
})
}
#[must_use]
pub fn parse_any(text: &str) -> Option<Temporal> {
for kind in [
TemporalKind::DateTime,
TemporalKind::Date,
TemporalKind::Time,
] {
if let Ok(value) = Temporal::parse(text, kind) {
return Some(value);
}
}
None
}
#[must_use]
pub fn to_seconds(&self) -> f64 {
self.to_seconds_in(0)
}
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn to_seconds_in(&self, implicit_minutes: i32) -> f64 {
let days = days_from_civil(self.year, self.month, self.day);
let offset = f64::from(self.offset_minutes.unwrap_or(implicit_minutes)) * 60.0;
(days as f64) * 86_400.0
+ f64::from(self.hour) * 3_600.0
+ f64::from(self.minute) * 60.0
+ self.second
- offset
}
#[must_use]
pub fn compare_in(&self, other: &Temporal, implicit_minutes: i32) -> Option<std::cmp::Ordering> {
self.to_seconds_in(implicit_minutes)
.partial_cmp(&other.to_seconds_in(implicit_minutes))
}
#[must_use]
pub fn to_lexical(&self) -> String {
let mut out = String::new();
if matches!(self.kind, TemporalKind::Date | TemporalKind::DateTime) {
out.push_str(&format!(
"{:04}-{:02}-{:02}",
self.year, self.month, self.day
));
}
if self.kind == TemporalKind::DateTime {
out.push('T');
}
if matches!(self.kind, TemporalKind::Time | TemporalKind::DateTime) {
out.push_str(&format!("{:02}:{:02}:", self.hour, self.minute));
if self.second.fract() == 0.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let whole = self.second as u32;
out.push_str(&format!("{whole:02}"));
} else {
out.push_str(&format!("{:09.6}", self.second));
}
}
match self.offset_minutes {
None => {}
Some(0) => out.push('Z'),
Some(minutes) => {
let sign = if minutes < 0 { '-' } else { '+' };
let minutes = minutes.abs();
out.push_str(&format!("{sign}{:02}:{:02}", minutes / 60, minutes % 60));
}
}
out
}
}
impl fmt::Display for Temporal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_lexical())
}
}
impl PartialEq for Temporal {
fn eq(&self, other: &Self) -> bool {
self.to_seconds() == other.to_seconds()
}
}
impl PartialOrd for Temporal {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.to_seconds().partial_cmp(&other.to_seconds())
}
}
fn split_timezone(text: &str) -> Option<(&str, Option<i32>)> {
if let Some(body) = text.strip_suffix('Z') {
return Some((body, Some(0)));
}
if text.len() >= 6 {
let (body, tail) = text.split_at(text.len() - 6);
let sign = match tail.as_bytes().first() {
Some(b'+') => 1,
Some(b'-') => -1,
_ => return Some((text, None)),
};
if !body.is_empty() {
let hours: i32 = tail.get(1..3)?.parse().ok()?;
if tail.as_bytes().get(3) != Some(&b':') {
return Some((text, None));
}
let minutes: i32 = tail.get(4..6)?.parse().ok()?;
if hours > 14 || minutes > 59 {
return None;
}
return Some((body, Some(sign * (hours * 60 + minutes))));
}
}
Some((text, None))
}
fn parse_date(text: &str) -> Option<(i64, u32, u32)> {
let (negative, rest) = match text.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, text),
};
let parts: Vec<&str> = rest.split('-').collect();
if parts.len() != 3 {
return None;
}
if parts[0].len() < 4 || parts[1].len() != 2 || parts[2].len() != 2 {
return None;
}
let year: i64 = parts[0].parse().ok()?;
let month: u32 = parts[1].parse().ok()?;
let day: u32 = parts[2].parse().ok()?;
Some((if negative { -year } else { year }, month, day))
}
fn parse_time(text: &str) -> Option<(u32, u32, f64)> {
let parts: Vec<&str> = text.split(':').collect();
if parts.len() != 3 || parts[0].len() != 2 || parts[1].len() != 2 {
return None;
}
let hour: u32 = parts[0].parse().ok()?;
let minute: u32 = parts[1].parse().ok()?;
let seconds_text = parts[2];
let integral = seconds_text.split('.').next()?;
if integral.len() != 2 || !seconds_text.chars().all(|c| c.is_ascii_digit() || c == '.') {
return None;
}
let second: f64 = seconds_text.parse().ok()?;
if hour > 24 || (hour == 24 && (minute != 0 || second != 0.0)) {
return None;
}
if minute > 59 || second >= 60.0 {
return None;
}
Some((hour, minute, second))
}
fn is_valid_civil(year: i64, month: u32, day: u32) -> bool {
if !(1..=12).contains(&month) || day < 1 {
return false;
}
day <= days_in_month(year, month)
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let year = if month <= 2 { year - 1 } else { year };
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let month = i64::from(month);
let day = i64::from(day);
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era =
year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let days = days + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
let year = if month <= 2 { year + 1 } else { year };
(
year,
u32::try_from(month).unwrap_or(1),
u32::try_from(day).unwrap_or(1),
)
}
#[must_use]
pub fn from_unix_seconds(kind: TemporalKind, seconds: f64) -> Temporal {
let days = (seconds / 86_400.0).floor();
let rest = seconds - days * 86_400.0;
#[allow(clippy::cast_possible_truncation)] let (year, month, day) = civil_from_days(days as i64);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let hour = (rest / 3_600.0).floor() as u32;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let minute = ((rest % 3_600.0) / 60.0).floor() as u32;
let second = rest % 60.0;
match kind {
TemporalKind::Date => Temporal::from_utc(kind, year, month, day, 0, 0, 0.0),
TemporalKind::DateTime => {
Temporal::from_utc(kind, year, month, day, hour, minute, second)
}
TemporalKind::Time => {
Temporal::from_utc(kind, TIME_REFERENCE.0, TIME_REFERENCE.1, TIME_REFERENCE.2, hour, minute, second)
}
}
}
#[cfg(test)]
#[allow(clippy::cast_precision_loss)] mod tests {
use super::*;
#[test]
fn parses_a_date() {
let d = Temporal::parse("2026-08-21", TemporalKind::Date).unwrap();
assert_eq!((d.year(), d.month(), d.day()), (2026, 8, 21));
assert_eq!(d.offset_minutes(), None);
}
#[test]
fn parses_a_date_time_with_fractional_seconds() {
let d = Temporal::parse("2026-08-21T10:30:05.25Z", TemporalKind::DateTime).unwrap();
assert_eq!((d.hour(), d.minute()), (10, 30));
assert!((d.second() - 5.25).abs() < f64::EPSILON);
assert_eq!(d.offset_minutes(), Some(0));
}
#[test]
fn parses_a_time() {
let t = Temporal::parse("23:59:59", TemporalKind::Time).unwrap();
assert_eq!((t.hour(), t.minute()), (23, 59));
}
#[test]
fn parses_timezone_offsets() {
let plus = Temporal::parse("2026-08-21+01:30", TemporalKind::Date).unwrap();
assert_eq!(plus.offset_minutes(), Some(90));
let minus = Temporal::parse("2026-08-21-05:00", TemporalKind::Date).unwrap();
assert_eq!(minus.offset_minutes(), Some(-300));
}
#[test]
fn an_offset_shifts_the_instant() {
let utc = Temporal::parse("2026-08-21T00:00:00Z", TemporalKind::DateTime).unwrap();
let ahead = Temporal::parse("2026-08-21T00:00:00+01:00", TemporalKind::DateTime).unwrap();
assert!(ahead < utc);
assert!((utc.to_seconds() - ahead.to_seconds() - 3600.0).abs() < f64::EPSILON);
}
#[test]
fn rejects_impossible_dates() {
for bad in [
"2026-02-30",
"2026-13-01",
"2026-00-01",
"2026-01-00",
"2025-02-29",
"not-a-date",
"2026-8-21",
"",
] {
assert!(
Temporal::parse(bad, TemporalKind::Date).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn accepts_leap_days_only_in_leap_years() {
assert!(Temporal::parse("2024-02-29", TemporalKind::Date).is_ok());
assert!(Temporal::parse("2000-02-29", TemporalKind::Date).is_ok());
assert!(Temporal::parse("1900-02-29", TemporalKind::Date).is_err());
assert!(Temporal::parse("2023-02-29", TemporalKind::Date).is_err());
}
#[test]
fn rejects_impossible_times() {
for bad in ["25:00:00", "10:60:00", "10:00:60", "24:00:01", "1:00:00"] {
assert!(
Temporal::parse(bad, TemporalKind::Time).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn orders_dates() {
let a = Temporal::parse("2020-01-01", TemporalKind::Date).unwrap();
let b = Temporal::parse("2026-08-21", TemporalKind::Date).unwrap();
assert!(a < b);
assert!(b > a);
assert_eq!(a, Temporal::parse("2020-01-01", TemporalKind::Date).unwrap());
}
#[test]
fn civil_conversion_round_trips() {
for (year, month, day) in [
(1970, 1, 1),
(2026, 8, 21),
(1900, 2, 28),
(2000, 2, 29),
(1, 1, 1),
(-44, 3, 15),
] {
let days = days_from_civil(year, month, day);
assert_eq!(civil_from_days(days), (year, month, day), "{year}-{month}-{day}");
}
}
#[test]
fn the_epoch_is_day_zero() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn builds_from_unix_seconds() {
let d = from_unix_seconds(TemporalKind::DateTime, 0.0);
assert_eq!((d.year(), d.month(), d.day()), (1970, 1, 1));
assert_eq!((d.hour(), d.minute()), (0, 0));
let seconds = days_from_civil(2026, 8, 21) as f64 * 86_400.0 + 43_200.0;
let d = from_unix_seconds(TemporalKind::DateTime, seconds);
assert_eq!((d.year(), d.month(), d.day()), (2026, 8, 21));
assert_eq!(d.hour(), 12);
}
#[test]
fn a_generated_date_has_no_time_of_day() {
let noon = days_from_civil(2026, 8, 21) as f64 * 86_400.0 + 43_200.0;
let generated = from_unix_seconds(TemporalKind::Date, noon);
let written = Temporal::parse("2026-08-21Z", TemporalKind::Date).unwrap();
assert_eq!(generated, written);
assert_eq!(generated.hour(), 0);
}
#[test]
fn a_generated_time_sits_on_the_reference_day() {
let noon = days_from_civil(2026, 8, 21) as f64 * 86_400.0 + 43_200.0;
let generated = from_unix_seconds(TemporalKind::Time, noon);
let written = Temporal::parse("12:00:00Z", TemporalKind::Time).unwrap();
assert_eq!(generated, written);
}
#[test]
fn lexical_form_round_trips() {
for (text, kind) in [
("2026-08-21Z", TemporalKind::Date),
("2026-08-21T10:30:05Z", TemporalKind::DateTime),
("10:30:05Z", TemporalKind::Time),
("2026-08-21+01:30", TemporalKind::Date),
] {
let parsed = Temporal::parse(text, kind).unwrap();
assert_eq!(parsed.to_lexical(), text);
}
}
#[test]
fn parse_any_picks_the_right_type() {
assert_eq!(
Temporal::parse_any("2026-08-21").unwrap().kind(),
TemporalKind::Date
);
assert_eq!(
Temporal::parse_any("2026-08-21T10:00:00").unwrap().kind(),
TemporalKind::DateTime
);
assert_eq!(
Temporal::parse_any("10:00:00").unwrap().kind(),
TemporalKind::Time
);
assert!(Temporal::parse_any("nonsense").is_none());
}
}
#[cfg(test)]
#[allow(clippy::cast_precision_loss)]
mod duration_tests {
use super::*;
#[test]
fn parses_day_time_durations() {
let d = Duration::parse("P90D", DurationKind::DayTime).unwrap();
assert_eq!(d.to_seconds(), 90.0 * 86_400.0);
let d = Duration::parse("P1DT2H30M15S", DurationKind::DayTime).unwrap();
assert_eq!(d.to_seconds(), 86_400.0 + 7_200.0 + 1_800.0 + 15.0);
let d = Duration::parse("PT90M", DurationKind::DayTime).unwrap();
assert_eq!(d.to_seconds(), 5_400.0);
}
#[test]
fn parses_year_month_durations() {
assert_eq!(
Duration::parse("P1Y6M", DurationKind::YearMonth).unwrap().to_months(),
18
);
assert_eq!(
Duration::parse("P3M", DurationKind::YearMonth).unwrap().to_months(),
3
);
assert_eq!(
Duration::parse("P2Y", DurationKind::YearMonth).unwrap().to_months(),
24
);
}
#[test]
fn m_means_months_before_the_t_and_minutes_after_it() {
assert_eq!(
Duration::parse("P1M", DurationKind::YearMonth).unwrap().to_months(),
1
);
assert_eq!(
Duration::parse("PT1M", DurationKind::DayTime).unwrap().to_seconds(),
60.0
);
}
#[test]
fn parses_negative_durations() {
assert_eq!(
Duration::parse("-P1D", DurationKind::DayTime).unwrap().to_seconds(),
-86_400.0
);
assert!(Duration::parse("-P1D", DurationKind::DayTime).unwrap().is_negative());
assert_eq!(
Duration::parse("-P6M", DurationKind::YearMonth).unwrap().to_months(),
-6
);
}
#[test]
fn each_subtype_rejects_the_others_fields() {
assert!(Duration::parse("P1M", DurationKind::DayTime).is_err());
assert!(Duration::parse("P1D", DurationKind::YearMonth).is_err());
assert!(Duration::parse("P1Y", DurationKind::DayTime).is_err());
}
#[test]
fn rejects_malformed_durations() {
for bad in ["", "P", "1D", "PD", "P1X", "PT", "P1DT", "P1M1Y", "PT1S1H", "P1"] {
assert!(
Duration::parse(bad, DurationKind::DayTime).is_err()
&& Duration::parse(bad, DurationKind::YearMonth).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn orders_durations_of_the_same_subtype() {
let short = Duration::parse("P1D", DurationKind::DayTime).unwrap();
let long = Duration::parse("P90D", DurationKind::DayTime).unwrap();
assert!(short < long);
assert_eq!(short, Duration::parse("PT24H", DurationKind::DayTime).unwrap());
}
#[test]
fn durations_of_different_subtypes_are_incomparable() {
let months = Duration::from_months(1);
let days = Duration::from_seconds(30.0 * 86_400.0);
assert!(months.partial_cmp(&days).is_none());
assert_ne!(months, days);
}
#[test]
fn lexical_form_round_trips() {
for (text, kind) in [
("P90D", DurationKind::DayTime),
("P1DT2H30M15S", DurationKind::DayTime),
("-P1D", DurationKind::DayTime),
("P1Y6M", DurationKind::YearMonth),
("P3M", DurationKind::YearMonth),
] {
let parsed = Duration::parse(text, kind).unwrap();
assert_eq!(parsed.to_lexical(), text, "{text}");
}
}
#[test]
fn adding_months_clamps_the_day() {
let january = Temporal::parse("2026-01-31", TemporalKind::Date).unwrap();
assert_eq!(add_months(&january, 1).to_lexical(), "2026-02-28");
let leap = Temporal::parse("2024-01-31", TemporalKind::Date).unwrap();
assert_eq!(add_months(&leap, 1).to_lexical(), "2024-02-29");
}
#[test]
fn adding_months_crosses_years_in_both_directions() {
let date = Temporal::parse("2026-08-21", TemporalKind::Date).unwrap();
assert_eq!(add_months(&date, 12).to_lexical(), "2027-08-21");
assert_eq!(add_months(&date, -12).to_lexical(), "2025-08-21");
assert_eq!(add_months(&date, 5).to_lexical(), "2027-01-21");
assert_eq!(add_months(&date, -8).to_lexical(), "2025-12-21");
}
#[test]
fn adding_seconds_moves_a_date() {
let date = Temporal::parse("2026-08-21", TemporalKind::Date).unwrap();
assert_eq!(add_seconds(&date, 86_400.0).to_lexical(), "2026-08-22");
assert_eq!(add_seconds(&date, -86_400.0).to_lexical(), "2026-08-20");
}
}