use std::time::Duration;
use thiserror::Error;
const MICROSECONDS_PER_MILLISECOND: u32 = 1_000;
const SECONDS_PER_MINUTE: u64 = 60;
const MINUTES_PER_HOUR: u64 = 60;
const SECONDS_PER_HOUR: u64 = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
#[must_use]
pub fn to_string(duration: Duration) -> String {
let mut string = String::new();
let mut seconds = duration.as_secs();
#[allow(clippy::integer_division)]
let hours = seconds / SECONDS_PER_HOUR;
push_value(&mut string, hours, "h");
seconds %= SECONDS_PER_HOUR;
#[allow(clippy::integer_division)]
let minutes = seconds / SECONDS_PER_MINUTE;
push_value(&mut string, minutes, "m");
seconds %= SECONDS_PER_MINUTE;
push_value(&mut string, seconds, "s");
let mut microseconds = duration.subsec_micros();
#[allow(clippy::integer_division)]
let milliseconds = microseconds / MICROSECONDS_PER_MILLISECOND;
push_value(&mut string, milliseconds.into(), "ms");
microseconds %= MICROSECONDS_PER_MILLISECOND;
push_value(&mut string, microseconds.into(), "us");
if string.is_empty() {
string.push_str("0s");
}
string
}
fn push_value(string: &mut String, value: u64, unit: &str) {
if value != 0 {
string.push_str(itoa::Buffer::new().format(value));
string.push_str(unit);
}
}
#[allow(clippy::missing_panics_doc)]
pub fn parse(mut s: &str) -> Result<Duration, ParseDurationError> {
if s.is_empty() {
return Err(ParseDurationError::Empty);
}
let mut duration = Duration::ZERO;
while !s.is_empty() {
let split = s
.find(|char: char| !char.is_ascii_digit())
.ok_or_else(|| ParseDurationError::MissingUnit(s.to_owned()))?;
let (value, rest) = s.split_at(split);
if value.is_empty() {
return Err(ParseDurationError::NoDigits(s.to_owned()));
}
let value: u64 = value.parse().expect("value is ASCII digits only");
let (unit, rest) = rest
.find(|char: char| char.is_ascii_digit())
.map_or((rest, ""), |split| rest.split_at(split));
s = rest;
match unit {
"h" => {
let value = value
.checked_mul(SECONDS_PER_HOUR)
.ok_or(ParseDurationError::Overflow)?;
add_duration(&mut duration, Duration::from_secs(value))?;
}
"m" => {
let value = value
.checked_mul(SECONDS_PER_MINUTE)
.ok_or(ParseDurationError::Overflow)?;
add_duration(&mut duration, Duration::from_secs(value))?;
}
"s" => add_duration(&mut duration, Duration::from_secs(value))?,
"ms" => add_duration(&mut duration, Duration::from_millis(value))?,
"us" => add_duration(&mut duration, Duration::from_micros(value))?,
unit => return Err(ParseDurationError::InvalidUnit(unit.to_owned())),
}
}
Ok(duration)
}
fn add_duration(duration: &mut Duration, rhs: Duration) -> Result<(), ParseDurationError> {
*duration = duration
.checked_add(rhs)
.ok_or(ParseDurationError::Overflow)?;
Ok(())
}
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum ParseDurationError {
#[error("cannot parse a duration from an empty string")]
Empty,
#[error("duration `{0}` does not have a unit")]
MissingUnit(String),
#[error("duration `{0}` does not contain ASCII digits (0-9)")]
NoDigits(String),
#[error("an overflow occurred")]
Overflow,
#[error("`{0}` is not a valid duration unit, must be `h`, `m`, `s`, `ms`, or `us`")]
InvalidUnit(String),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub(crate) mod tests {
use proptest::{prop_assert_eq, prop_compose, proptest};
use super::*;
mod to_string {
use super::*;
#[test]
fn hours() {
let test = Duration::from_secs(3 * SECONDS_PER_HOUR);
assert_eq!(to_string(test), "3h");
}
#[test]
fn minutes() {
let test = Duration::from_secs(3 * SECONDS_PER_MINUTE);
assert_eq!(to_string(test), "3m");
}
#[test]
fn seconds() {
let test = Duration::from_secs(3);
assert_eq!(to_string(test), "3s");
}
#[test]
fn milliseconds() {
let test = Duration::from_millis(3);
assert_eq!(to_string(test), "3ms");
}
#[test]
fn microseconds() {
let test = Duration::from_micros(3);
assert_eq!(to_string(test), "3us");
}
#[test]
fn combination() {
let test = Duration::from_secs((3 * SECONDS_PER_HOUR) + (3 * SECONDS_PER_MINUTE) + 3);
assert_eq!(to_string(test), "3h3m3s");
}
}
mod parse {
use super::*;
#[test]
fn hours() {
let test = Duration::from_secs(3 * SECONDS_PER_HOUR);
assert_eq!(parse("3h").unwrap(), test);
}
#[test]
fn minutes() {
let test = Duration::from_secs(3 * SECONDS_PER_MINUTE);
assert_eq!(parse("3m").unwrap(), test);
}
#[test]
fn seconds() {
let test = Duration::from_secs(3);
assert_eq!(parse("3s").unwrap(), test);
}
#[test]
fn milliseconds() {
let test = Duration::from_millis(3);
assert_eq!(parse("3ms").unwrap(), test);
}
#[test]
fn microseconds() {
let test = Duration::from_micros(3);
assert_eq!(parse("3us").unwrap(), test);
}
#[test]
fn combination() {
let test = Duration::from_secs((3 * SECONDS_PER_HOUR) + (3 * SECONDS_PER_MINUTE) + 3);
assert_eq!(parse("3h3m3s").unwrap(), test);
}
#[test]
fn missing_unit_err() {
assert_eq!(
parse("42").unwrap_err(),
ParseDurationError::MissingUnit(String::from("42")),
);
}
#[test]
fn no_digits_err() {
assert_eq!(
parse(" ").unwrap_err(),
ParseDurationError::NoDigits(String::from(" ")),
);
}
}
proptest! {
#[test]
fn to_string_no_panic(duration: Duration) {
let _ = to_string(duration);
}
#[test]
fn parse_no_panic(duration: String) {
let _ = parse(&duration);
}
#[test]
fn round_trip(duration in duration_truncated()) {
prop_assert_eq!(duration, parse(&to_string(duration))?);
}
}
prop_compose! {
#[allow(clippy::integer_division)]
pub(crate) fn duration_truncated()(secs: u64, micros in ..=(u32::MAX / 1000)) -> Duration {
Duration::new(secs, micros * 1000)
}
}
}