use thiserror::Error;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum Iso8601DurationError {
#[error("not an ISO 8601 duration: {reason}")]
Malformed {
reason: &'static str,
},
#[error(
"'{unit}' has no fixed length ({}); resolve it against a start date instead",
if *unit == 'Y' { "a year is 365 or 366 days" } else { "a month is 28 to 31 days" }
)]
CalendarComponent {
unit: char,
},
#[error("duration is out of range for time::Duration")]
OutOfRange,
}
pub fn parse(s: &str) -> Result<time::Duration, Iso8601DurationError> {
fn malformed(reason: &'static str) -> Iso8601DurationError {
Iso8601DurationError::Malformed { reason }
}
let (negative, rest) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
let body = rest
.strip_prefix('P')
.ok_or_else(|| malformed("must start with 'P'"))?;
if body.is_empty() {
return Err(malformed("'P' with no components"));
}
let (date_part, time_part) = match body.split_once('T') {
Some((d, t)) => {
if t.is_empty() {
return Err(malformed("'T' with no time components"));
}
(d, Some(t))
}
None => (body, None),
};
let mut seconds = 0f64;
let mut any = false;
for (value, unit) in Components::new(date_part) {
let value = value?;
any = true;
seconds += match unit {
'Y' | 'M' => return Err(Iso8601DurationError::CalendarComponent { unit }),
'W' => value * 604_800.0, 'D' => value * 86_400.0,
_ => return Err(malformed("unknown unit in the date part")),
};
}
if let Some(time_part) = time_part {
for (value, unit) in Components::new(time_part) {
let value = value?;
any = true;
seconds += match unit {
'H' => value * 3_600.0,
'M' => value * 60.0,
'S' => value,
_ => return Err(malformed("unknown unit in the time part")),
};
}
}
if !any {
return Err(malformed("no components"));
}
if !seconds.is_finite() {
return Err(Iso8601DurationError::OutOfRange);
}
let signed = if negative { -seconds } else { seconds };
time::Duration::checked_seconds_f64(signed).ok_or(Iso8601DurationError::OutOfRange)
}
struct Components<'a> {
rest: &'a str,
}
impl<'a> Components<'a> {
fn new(rest: &'a str) -> Self {
Self { rest }
}
}
impl Iterator for Components<'_> {
type Item = (Result<f64, Iso8601DurationError>, char);
fn next(&mut self) -> Option<Self::Item> {
if self.rest.is_empty() {
return None;
}
let split = self
.rest
.find(|c: char| c.is_ascii_alphabetic())
.unwrap_or(self.rest.len());
let (digits, tail) = self.rest.split_at(split);
let mut chars = tail.chars();
let Some(unit) = chars.next() else {
self.rest = "";
return Some((
Err(Iso8601DurationError::Malformed {
reason: "a value with no unit",
}),
'?',
));
};
self.rest = chars.as_str();
let normalised = digits.replace(',', ".");
let value = if normalised.is_empty() {
Err(Iso8601DurationError::Malformed {
reason: "a unit with no value",
})
} else if !normalised.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
Err(Iso8601DurationError::Malformed {
reason: "a component value must be a non-negative number",
})
} else {
normalised
.parse::<f64>()
.map_err(|_| Iso8601DurationError::Malformed {
reason: "a component value must be a non-negative number",
})
};
Some((value, unit))
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::Duration;
#[test]
fn parses_the_bo4e_example() {
assert_eq!(
parse("P1DT30H4S"),
Ok(Duration::days(1) + Duration::hours(30) + Duration::seconds(4))
);
}
#[test]
fn parses_each_exact_unit() {
assert_eq!(parse("P3D"), Ok(Duration::days(3)));
assert_eq!(parse("PT4H"), Ok(Duration::hours(4)));
assert_eq!(parse("PT15M"), Ok(Duration::minutes(15)));
assert_eq!(parse("PT30S"), Ok(Duration::seconds(30)));
assert_eq!(parse("P1W"), Ok(Duration::days(7)));
assert_eq!(parse("P2W3DT4H5M6S"), Ok(Duration::seconds(1_483_506)));
}
#[test]
fn m_means_months_before_t_and_minutes_after() {
assert_eq!(
parse("P1M"),
Err(Iso8601DurationError::CalendarComponent { unit: 'M' })
);
assert_eq!(parse("PT1M"), Ok(Duration::minutes(1)));
assert_eq!(
parse("P1MT1M"),
Err(Iso8601DurationError::CalendarComponent { unit: 'M' })
);
}
#[test]
fn years_and_months_are_refused_rather_than_approximated() {
assert_eq!(
parse("P1Y"),
Err(Iso8601DurationError::CalendarComponent { unit: 'Y' })
);
let err = parse("P1Y").unwrap_err().to_string();
assert!(err.contains("365 or 366"), "unhelpful message: {err}");
let err = parse("P2M").unwrap_err().to_string();
assert!(err.contains("28 to 31"), "unhelpful message: {err}");
}
#[test]
fn accepts_a_decimal_fraction_with_either_separator() {
assert_eq!(parse("PT0.5S"), Ok(Duration::milliseconds(500)));
assert_eq!(parse("PT0,5S"), Ok(Duration::milliseconds(500)));
assert_eq!(parse("PT1.5H"), Ok(Duration::minutes(90)));
}
#[test]
fn accepts_a_sign() {
assert_eq!(parse("-P1D"), Ok(Duration::days(-1)));
assert_eq!(parse("+P1D"), Ok(Duration::days(1)));
}
#[test]
fn rejects_what_is_not_a_duration() {
for bad in [
"",
"1D",
"p1d",
"P",
"PT",
"PD",
"PTS",
"P1",
"PT1",
"P-1D",
"PXY",
"P1DX",
"2026-01-01",
"PT1H1",
] {
assert!(
parse(bad).is_err(),
"{bad:?} must not parse as a duration, got {:?}",
parse(bad)
);
}
}
#[test]
fn is_case_sensitive() {
assert!(parse("p1dt30h4s").is_err());
assert!(parse("P1dT30h4s").is_err());
}
#[test]
fn reports_out_of_range_rather_than_saturating() {
assert_eq!(
parse("P999999999999999999999D"),
Err(Iso8601DurationError::OutOfRange)
);
}
#[test]
fn never_panics_on_arbitrary_input() {
for s in [
"P", "PT", "P1DT", "PW", "P,S", "P..1D", "PTT1S", "P1D1", "-", "+P",
] {
let _ = parse(s);
}
}
}