use std::f64;
use crate::prelude::*;
use chrono::Duration;
fn parse_components(
s: &str,
allowed_units: &[char],
original_input: &str,
) -> Result<Vec<(f64, char)>> {
let mut result = Vec::new();
let mut iter = s.chars().peekable();
while iter.peek().is_some() {
let mut num_str = String::new();
let mut has_decimal = false;
while let Some(&c) = iter.peek() {
if c.is_ascii_digit() || (c == '.' && !has_decimal) {
if c == '.' {
has_decimal = true;
}
num_str.push(iter.next().unwrap());
} else {
break;
}
}
if num_str.is_empty() {
client_bail!("Expected number in: {}", original_input);
}
let num = num_str
.parse::<f64>()
.map_err(|_| client_error!("Invalid number '{}' in: {}", num_str, original_input))?;
if let Some(&unit) = iter.peek() {
if allowed_units.contains(&unit) {
result.push((num, unit));
iter.next();
} else {
client_bail!("Invalid unit '{}' in: {}", unit, original_input);
}
} else {
client_bail!(
"Missing unit after number '{}' in: {}",
num_str,
original_input
);
}
}
Ok(result)
}
fn parse_iso8601_duration(s: &str, original_input: &str) -> Result<Duration> {
let (is_negative, s_after_sign) = if let Some(stripped) = s.strip_prefix('-') {
(true, stripped)
} else {
(false, s)
};
if !s_after_sign.starts_with('P') {
client_bail!("Duration must start with 'P' in: {}", original_input);
}
let s_after_p = &s_after_sign[1..];
let (date_part, time_part) = if let Some(pos) = s_after_p.find('T') {
(&s_after_p[..pos], Some(&s_after_p[pos + 1..]))
} else {
(s_after_p, None)
};
let date_components = parse_components(date_part, &['Y', 'M', 'W', 'D'], original_input)?;
let time_components = if let Some(time_str) = time_part {
let comps = parse_components(time_str, &['H', 'M', 'S'], original_input)?;
if comps.is_empty() {
client_bail!(
"Time part present but no time components in: {}",
original_input
);
}
comps
} else {
vec![]
};
if date_components.is_empty() && time_components.is_empty() {
client_bail!("No components in duration: {}", original_input);
}
let date_duration = date_components
.iter()
.fold(Duration::zero(), |acc, &(num, unit)| {
let days = match unit {
'Y' => num * 365.0,
'M' => num * 30.0,
'W' => num * 7.0,
'D' => num,
_ => unreachable!("Invalid date unit should be caught by prior validation"),
};
let microseconds = (days * 86_400_000_000.0) as i64;
acc + Duration::microseconds(microseconds)
});
let time_duration =
time_components
.iter()
.fold(Duration::zero(), |acc, &(num, unit)| match unit {
'H' => {
let nanoseconds = (num * 3_600_000_000_000.0).round() as i64;
acc + Duration::nanoseconds(nanoseconds)
}
'M' => {
let nanoseconds = (num * 60_000_000_000.0).round() as i64;
acc + Duration::nanoseconds(nanoseconds)
}
'S' => {
let nanoseconds = (num.fract() * 1_000_000_000.0).round() as i64;
acc + Duration::seconds(num as i64) + Duration::nanoseconds(nanoseconds)
}
_ => unreachable!("Invalid time unit should be caught by prior validation"),
});
let mut total = date_duration + time_duration;
if is_negative {
total = -total;
}
Ok(total)
}
fn parse_human_readable_duration(s: &str, original_input: &str) -> Result<Duration> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.is_empty() || !parts.len().is_multiple_of(2) {
client_bail!(
"Invalid human-readable duration format in: {}",
original_input
);
}
let durations: Result<Vec<Duration>> = parts
.chunks(2)
.map(|chunk| {
let num: i64 = chunk[0].parse().map_err(|_| {
client_error!("Invalid number '{}' in: {}", chunk[0], original_input)
})?;
match chunk[1].to_lowercase().as_str() {
"day" | "days" => Ok(Duration::days(num)),
"hour" | "hours" => Ok(Duration::hours(num)),
"minute" | "minutes" => Ok(Duration::minutes(num)),
"second" | "seconds" => Ok(Duration::seconds(num)),
"millisecond" | "milliseconds" => Ok(Duration::milliseconds(num)),
"microsecond" | "microseconds" => Ok(Duration::microseconds(num)),
_ => client_bail!("Invalid unit '{}' in: {}", chunk[1], original_input),
}
})
.collect();
durations.map(|durs| durs.into_iter().sum())
}
pub fn parse_duration(s: &str) -> Result<Duration> {
let original_input = s;
let s = s.trim();
if s.is_empty() {
client_bail!("Empty duration string");
}
let is_likely_iso8601 = match s.as_bytes() {
[c, ..] if c.eq_ignore_ascii_case(&b'P') => true,
[b'-', c, ..] if c.eq_ignore_ascii_case(&b'P') => true,
_ => false,
};
if is_likely_iso8601 {
parse_iso8601_duration(s, original_input)
} else {
parse_human_readable_duration(s, original_input)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check_ok(res: Result<Duration>, expected: Duration, input_str: &str) {
match res {
Ok(duration) => assert_eq!(duration, expected, "Input: '{input_str}'"),
Err(e) => panic!("Input: '{input_str}', expected Ok({expected:?}), but got Err: {e}"),
}
}
fn check_err_contains(res: Result<Duration>, expected_substring: &str, input_str: &str) {
match res {
Ok(d) => panic!(
"Input: '{input_str}', expected error containing '{expected_substring}', but got Ok({d:?})"
),
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains(expected_substring),
"Input: '{input_str}', error message '{err_msg}' does not contain expected substring '{expected_substring}'"
);
}
}
}
#[test]
fn test_empty_string() {
check_err_contains(parse_duration(""), "Empty duration string", "\"\"");
}
#[test]
fn test_whitespace_string() {
check_err_contains(parse_duration(" "), "Empty duration string", "\" \"");
}
#[test]
fn test_iso_just_p() {
check_err_contains(parse_duration("P"), "No components in duration: P", "\"P\"");
}
#[test]
fn test_iso_pt() {
check_err_contains(
parse_duration("PT"),
"Time part present but no time components in: PT",
"\"PT\"",
);
}
#[test]
fn test_iso_missing_number_before_unit_in_date_part() {
check_err_contains(parse_duration("PD"), "Expected number in: PD", "\"PD\"");
}
#[test]
fn test_iso_missing_number_before_unit_in_time_part() {
check_err_contains(parse_duration("PTM"), "Expected number in: PTM", "\"PTM\"");
}
#[test]
fn test_iso_time_unit_without_t() {
check_err_contains(parse_duration("P1H"), "Invalid unit 'H' in: P1H", "\"P1H\"");
check_err_contains(parse_duration("P1S"), "Invalid unit 'S' in: P1S", "\"P1S\"");
}
#[test]
fn test_iso_invalid_unit() {
check_err_contains(parse_duration("P1X"), "Invalid unit 'X' in: P1X", "\"P1X\"");
check_err_contains(
parse_duration("PT1X"),
"Invalid unit 'X' in: PT1X",
"\"PT1X\"",
);
}
#[test]
fn test_iso_valid_lowercase_unit_is_not_allowed() {
check_err_contains(
parse_duration("p1h"),
"Duration must start with 'P' in: p1h",
"\"p1h\"",
);
check_err_contains(
parse_duration("PT1h"),
"Invalid unit 'h' in: PT1h",
"\"PT1h\"",
);
}
#[test]
fn test_iso_trailing_number_error() {
check_err_contains(
parse_duration("P1D2"),
"Missing unit after number '2' in: P1D2",
"\"P1D2\"",
);
}
#[test]
fn test_iso_invalid_fractional_format() {
check_err_contains(
parse_duration("PT1..5S"),
"Invalid unit '.' in: PT1..5S",
"\"PT1..5S\"",
);
check_err_contains(
parse_duration("PT1.5.5S"),
"Invalid unit '.' in: PT1.5.5S",
"\"PT1.5.5S\"",
);
check_err_contains(
parse_duration("P1..5D"),
"Invalid unit '.' in: P1..5D",
"\"P1..5D\"",
);
}
#[test]
fn test_iso_misplaced_t() {
check_err_contains(
parse_duration("P1DT2H T3M"),
"Expected number in: P1DT2H T3M",
"\"P1DT2H T3M\"",
);
check_err_contains(
parse_duration("P1T2H"),
"Missing unit after number '1' in: P1T2H",
"\"P1T2H\"",
);
}
#[test]
fn test_iso_negative_number_after_p() {
check_err_contains(
parse_duration("P-1D"),
"Expected number in: P-1D",
"\"P-1D\"",
);
}
#[test]
fn test_iso_valid_months() {
check_ok(parse_duration("P1M"), Duration::days(30), "\"P1M\"");
check_ok(parse_duration(" P13M"), Duration::days(13 * 30), "\"P13M\"");
}
#[test]
fn test_iso_valid_weeks() {
check_ok(parse_duration("P1W"), Duration::days(7), "\"P1W\"");
check_ok(parse_duration(" P1W "), Duration::days(7), "\"P1W\"");
}
#[test]
fn test_iso_valid_days() {
check_ok(parse_duration("P1D"), Duration::days(1), "\"P1D\"");
}
#[test]
fn test_iso_valid_hours() {
check_ok(parse_duration("PT2H"), Duration::hours(2), "\"PT2H\"");
}
#[test]
fn test_iso_valid_minutes() {
check_ok(parse_duration("PT3M"), Duration::minutes(3), "\"PT3M\"");
}
#[test]
fn test_iso_valid_seconds() {
check_ok(parse_duration("PT4S"), Duration::seconds(4), "\"PT4S\"");
}
#[test]
fn test_iso_combined_units() {
check_ok(
parse_duration("P1Y2M3W4DT5H6M7S"),
Duration::days(365 + 60 + 3 * 7 + 4)
+ Duration::hours(5)
+ Duration::minutes(6)
+ Duration::seconds(7),
"\"P1Y2M3DT4H5M6S\"",
);
check_ok(
parse_duration("P1DT2H3M4S"),
Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4),
"\"P1DT2H3M4S\"",
);
}
#[test]
fn test_iso_duplicated_unit() {
check_ok(parse_duration("P1D1D"), Duration::days(2), "\"P1D1D\"");
check_ok(parse_duration("PT1H1H"), Duration::hours(2), "\"PT1H1H\"");
}
#[test]
fn test_iso_out_of_order_unit() {
check_ok(
parse_duration("P1W1Y"),
Duration::days(365 + 7),
"\"P1W1Y\"",
);
check_ok(
parse_duration("PT2S1H"),
Duration::hours(1) + Duration::seconds(2),
"\"PT2S1H\"",
);
check_ok(parse_duration("P3M"), Duration::days(90), "\"PT2S1H\"");
check_ok(parse_duration("PT3M"), Duration::minutes(3), "\"PT2S1H\"");
check_err_contains(
parse_duration("P1H2D"),
"Invalid unit 'H' in: P1H2D", "\"P1H2D\"",
);
}
#[test]
fn test_iso_negative_duration_p1d() {
check_ok(parse_duration("-P1D"), -Duration::days(1), "\"-P1D\"");
}
#[test]
fn test_iso_zero_duration_pd0() {
check_ok(parse_duration("P0D"), Duration::zero(), "\"P0D\"");
}
#[test]
fn test_iso_zero_duration_pt0s() {
check_ok(parse_duration("PT0S"), Duration::zero(), "\"PT0S\"");
}
#[test]
fn test_iso_zero_duration_pt0h0m0s() {
check_ok(parse_duration("PT0H0M0S"), Duration::zero(), "\"PT0H0M0S\"");
}
#[test]
fn test_iso_fractional_seconds() {
check_ok(
parse_duration("PT1.5S"),
Duration::seconds(1) + Duration::milliseconds(500),
"\"PT1.5S\"",
);
check_ok(
parse_duration("PT441010.456123S"),
Duration::seconds(441010) + Duration::microseconds(456123),
"\"PT441010.456123S\"",
);
check_ok(
parse_duration("PT0.000001S"),
Duration::microseconds(1),
"\"PT0.000001S\"",
);
}
#[test]
fn test_iso_fractional_date_units() {
check_ok(
parse_duration("P1.5D"),
Duration::microseconds((1.5 * 86_400_000_000.0) as i64),
"\"P1.5D\"",
);
check_ok(
parse_duration("P1.25Y"),
Duration::microseconds((1.25 * 365.0 * 86_400_000_000.0) as i64),
"\"P1.25Y\"",
);
check_ok(
parse_duration("P2.75M"),
Duration::microseconds((2.75 * 30.0 * 86_400_000_000.0) as i64),
"\"P2.75M\"",
);
check_ok(
parse_duration("P0.5W"),
Duration::microseconds((0.5 * 7.0 * 86_400_000_000.0) as i64),
"\"P0.5W\"",
);
}
#[test]
fn test_iso_negative_fractional_date_units() {
check_ok(
parse_duration("-P1.5D"),
-Duration::microseconds((1.5 * 86_400_000_000.0) as i64),
"\"-P1.5D\"",
);
check_ok(
parse_duration("-P0.25Y"),
-Duration::microseconds((0.25 * 365.0 * 86_400_000_000.0) as i64),
"\"-P0.25Y\"",
);
}
#[test]
fn test_iso_combined_fractional_units() {
check_ok(
parse_duration("P1.5DT2.5H3.5M4.5S"),
Duration::microseconds((1.5 * 86_400_000_000.0) as i64)
+ Duration::microseconds((2.5 * 3_600_000_000.0) as i64)
+ Duration::microseconds((3.5 * 60_000_000.0) as i64)
+ Duration::seconds(4)
+ Duration::milliseconds(500),
"\"1.5DT2.5H3.5M4.5S\"",
);
}
#[test]
fn test_iso_multiple_fractional_time_units() {
check_ok(
parse_duration("PT1.5S2.5S"),
Duration::seconds(1 + 2) + Duration::milliseconds(500) + Duration::milliseconds(500),
"\"PT1.5S2.5S\"",
);
check_ok(
parse_duration("PT1.1H2.2M3.3S"),
Duration::hours(1)
+ Duration::seconds((0.1 * 3600.0) as i64)
+ Duration::minutes(2)
+ Duration::seconds((0.2 * 60.0) as i64)
+ Duration::seconds(3)
+ Duration::milliseconds(300),
"\"PT1.1H2.2M3.3S\"",
);
}
#[test]
fn test_human_missing_unit() {
check_err_contains(
parse_duration("1"),
"Invalid human-readable duration format in: 1",
"\"1\"",
);
}
#[test]
fn test_human_missing_number() {
check_err_contains(
parse_duration("day"),
"Invalid human-readable duration format in: day",
"\"day\"",
);
}
#[test]
fn test_human_incomplete_pair() {
check_err_contains(
parse_duration("1 day 2"),
"Invalid human-readable duration format in: 1 day 2",
"\"1 day 2\"",
);
}
#[test]
fn test_human_invalid_number_at_start() {
check_err_contains(
parse_duration("one day"),
"Invalid number 'one' in: one day",
"\"one day\"",
);
}
#[test]
fn test_human_invalid_unit() {
check_err_contains(
parse_duration("1 hour 2 minutes 3 seconds four seconds"),
"Invalid number 'four' in: 1 hour 2 minutes 3 seconds four seconds",
"\"1 hour 2 minutes 3 seconds four seconds\"",
);
}
#[test]
fn test_human_float_number_fail() {
check_err_contains(
parse_duration("1.5 hours"),
"Invalid number '1.5' in: 1.5 hours",
"\"1.5 hours\"",
);
}
#[test]
fn test_invalid_human_readable_no_pairs() {
check_err_contains(
parse_duration("just some words"),
"Invalid human-readable duration format in: just some words",
"\"just some words\"",
);
}
#[test]
fn test_human_unknown_unit() {
check_err_contains(
parse_duration("1 year"),
"Invalid unit 'year' in: 1 year",
"\"1 year\"",
);
}
#[test]
fn test_human_valid_day() {
check_ok(parse_duration("1 day"), Duration::days(1), "\"1 day\"");
}
#[test]
fn test_human_valid_days_uppercase() {
check_ok(parse_duration("2 DAYS"), Duration::days(2), "\"2 DAYS\"");
}
#[test]
fn test_human_valid_hour() {
check_ok(parse_duration("3 hour"), Duration::hours(3), "\"3 hour\"");
}
#[test]
fn test_human_valid_hours_mixedcase() {
check_ok(parse_duration("4 HoUrS"), Duration::hours(4), "\"4 HoUrS\"");
}
#[test]
fn test_human_valid_minute() {
check_ok(
parse_duration("5 minute"),
Duration::minutes(5),
"\"5 minute\"",
);
}
#[test]
fn test_human_valid_minutes() {
check_ok(
parse_duration("6 minutes"),
Duration::minutes(6),
"\"6 minutes\"",
);
}
#[test]
fn test_human_valid_second() {
check_ok(
parse_duration("7 second"),
Duration::seconds(7),
"\"7 second\"",
);
}
#[test]
fn test_human_valid_seconds() {
check_ok(
parse_duration("8 seconds"),
Duration::seconds(8),
"\"8 seconds\"",
);
}
#[test]
fn test_human_valid_millisecond() {
check_ok(
parse_duration("9 millisecond"),
Duration::milliseconds(9),
"\"9 millisecond\"",
);
}
#[test]
fn test_human_valid_milliseconds() {
check_ok(
parse_duration("10 milliseconds"),
Duration::milliseconds(10),
"\"10 milliseconds\"",
);
}
#[test]
fn test_human_valid_microsecond() {
check_ok(
parse_duration("11 microsecond"),
Duration::microseconds(11),
"\"11 microsecond\"",
);
}
#[test]
fn test_human_valid_microseconds() {
check_ok(
parse_duration("12 microseconds"),
Duration::microseconds(12),
"\"12 microseconds\"",
);
}
#[test]
fn test_human_combined() {
let expected =
Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4);
check_ok(
parse_duration("1 day 2 hours 3 minutes 4 seconds"),
expected,
"\"1 day 2 hours 3 minutes 4 seconds\"",
);
}
#[test]
fn test_human_out_of_order() {
check_ok(
parse_duration("1 second 2 hours"),
Duration::hours(2) + Duration::seconds(1),
"\"1 second 2 hours\"",
);
check_ok(
parse_duration("7 minutes 6 hours 5 days"),
Duration::days(5) + Duration::hours(6) + Duration::minutes(7),
"\"7 minutes 6 hours 5 days\"",
)
}
#[test]
fn test_human_zero_duration_seconds() {
check_ok(
parse_duration("0 seconds"),
Duration::zero(),
"\"0 seconds\"",
);
}
#[test]
fn test_human_zero_duration_days_hours() {
check_ok(
parse_duration("0 day 0 hour"),
Duration::zero(),
"\"0 day 0 hour\"",
);
}
#[test]
fn test_human_zero_duration_multiple_zeros() {
check_ok(
parse_duration("0 days 0 hours 0 minutes 0 seconds"),
Duration::zero(),
"\"0 days 0 hours 0 minutes 0 seconds\"",
);
}
#[test]
fn test_human_no_space_between_num_unit() {
check_err_contains(
parse_duration("1day"),
"Invalid human-readable duration format in: 1day",
"\"1day\"",
);
}
#[test]
fn test_human_trimmed() {
check_ok(parse_duration(" 1 day "), Duration::days(1), "\" 1 day \"");
}
#[test]
fn test_human_extra_whitespace() {
check_ok(
parse_duration(" 1 day 2 hours "),
Duration::days(1) + Duration::hours(2),
"\" 1 day 2 hours \"",
);
}
#[test]
fn test_human_negative_numbers() {
check_ok(
parse_duration("-1 day 2 hours"),
Duration::days(-1) + Duration::hours(2),
"\"-1 day 2 hours\"",
);
check_ok(
parse_duration("1 day -2 hours"),
Duration::days(1) + Duration::hours(-2),
"\"1 day -2 hours\"",
);
}
}