use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DashParseError {
InvalidDuration {
value: String,
},
}
impl fmt::Display for DashParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DashParseError::InvalidDuration { value } => {
write!(f, "invalid xs:duration {value:?}")
}
}
}
}
impl std::error::Error for DashParseError {}
pub fn parse_iso8601_duration(s: &str) -> Result<Duration> {
let trimmed = s.trim();
let invalid = || DashParseError::InvalidDuration {
value: trimmed.to_string(),
};
let normalised = normalise_xs_duration(trimmed).ok_or_else(invalid)?;
let span = jiff::fmt::temporal::SpanParser::new()
.parse_span(normalised.as_str())
.map_err(|_| invalid())?;
let signed = span
.to_duration(jiff::SpanRelativeTo::days_are_24_hours())
.map_err(|_| invalid())?;
Duration::try_from(signed).map_err(|_| invalid())
}
#[must_use]
pub fn xs_duration(d: Duration) -> String {
let secs = d.as_secs();
let _ = secs;
let signed = jiff::SignedDuration::try_from(d).unwrap_or(jiff::SignedDuration::MAX);
jiff::fmt::temporal::SpanPrinter::new().duration_to_string(&signed)
}
fn normalise_xs_duration(s: &str) -> Option<String> {
let rest = s.strip_prefix('P')?;
let (date_part, time_part) = match rest.split_once('T') {
Some((d, t)) => (d, Some(t)),
None => (rest, None),
};
let mut components = 0usize;
let mut out = String::from("P");
let mut remaining = date_part;
for designator in ['Y', 'M', 'D'] {
if let Some((digits, tail)) = remaining.split_once(designator) {
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
out.push_str(digits);
out.push(designator);
remaining = tail;
components += 1;
}
}
if !remaining.is_empty() {
return None;
}
if let Some(time) = time_part {
out.push('T');
let mut remaining = time;
let mut time_components = 0usize;
for designator in ['H', 'M'] {
if let Some((digits, tail)) = remaining.split_once(designator) {
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
out.push_str(digits);
out.push(designator);
remaining = tail;
time_components += 1;
}
}
if let Some((number, tail)) = remaining.split_once('S') {
if !tail.is_empty() {
return None;
}
let (whole, frac) = match number.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (number, None),
};
if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
out.push_str(whole);
if let Some(frac) = frac {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
out.push('.');
out.push_str(&frac[..frac.len().min(9)]);
}
out.push('S');
time_components += 1;
} else if !remaining.is_empty() {
return None;
}
if time_components == 0 {
return None;
}
components += time_components;
}
(components > 0).then_some(out)
}
type Result<T> = core::result::Result<T, DashParseError>;
#[cfg(test)]
mod tests {
use super::*;
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
const SECONDS_PER_HOUR: u64 = 60 * 60;
#[test]
fn hours_minutes_fractional_seconds() {
assert_eq!(
parse_iso8601_duration("PT1H2M3.5S").unwrap(),
Duration::new(3723, 500_000_000)
);
}
#[test]
fn seconds_only() {
assert_eq!(parse_iso8601_duration("PT4S").unwrap(), Duration::new(4, 0));
}
#[test]
fn zero() {
assert_eq!(parse_iso8601_duration("PT0S").unwrap(), Duration::new(0, 0));
}
#[test]
fn days_and_hours() {
assert_eq!(
parse_iso8601_duration("P1DT2H").unwrap(),
Duration::new(SECONDS_PER_DAY + 2 * SECONDS_PER_HOUR, 0)
);
}
#[test]
fn writer_tenths_form() {
assert_eq!(
parse_iso8601_duration("PT2.0S").unwrap(),
Duration::new(2, 0)
);
}
#[test]
fn rejects_bare_p() {
assert!(matches!(
parse_iso8601_duration("P"),
Err(DashParseError::InvalidDuration { .. })
));
}
#[test]
fn rejects_missing_prefix() {
assert!(matches!(
parse_iso8601_duration("1H2M3S"),
Err(DashParseError::InvalidDuration { .. })
));
}
#[test]
fn rejects_calendar_months() {
assert!(matches!(
parse_iso8601_duration("P1M"),
Err(DashParseError::InvalidDuration { .. })
));
}
#[test]
fn xs_duration_saturates_instead_of_panicking_beyond_i64_seconds() {
let max =
jiff::fmt::temporal::SpanPrinter::new().duration_to_string(&jiff::SignedDuration::MAX);
assert_eq!(xs_duration(Duration::MAX), max);
assert_eq!(xs_duration(Duration::from_secs(i64::MAX as u64 + 1)), max);
assert_ne!(xs_duration(Duration::from_secs(i64::MAX as u64)), "PT0S");
}
#[test]
fn xs_duration_is_the_shortest_iso_8601_form() {
assert_eq!(xs_duration(Duration::from_secs(2)), "PT2S");
assert_eq!(xs_duration(Duration::ZERO), "PT0S");
assert_eq!(xs_duration(Duration::from_millis(2500)), "PT2.5S");
assert_eq!(xs_duration(Duration::from_secs(90)), "PT1M30S");
assert_eq!(xs_duration(Duration::from_secs(3600)), "PT1H");
for d in [
Duration::from_millis(100),
Duration::from_secs(7200),
Duration::new(61, 500_000_000),
] {
assert_eq!(parse_iso8601_duration(&xs_duration(d)).unwrap(), d);
}
}
#[test]
fn format_round_trips() {
for s in ["PT0S", "PT4S", "PT1H2M3.5S", "P1DT2H"] {
let d = parse_iso8601_duration(s).unwrap();
assert_eq!(parse_iso8601_duration(&xs_duration(d)).unwrap(), d);
}
}
}