use std::time::Duration;
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let input = input.trim();
if input.is_empty() {
return Err("empty duration string".into());
}
if input.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
let n: f64 = input.parse().map_err(|_| {
format!(
"invalid duration '{}': expected a number with optional unit (ms, s, m, h)",
input
)
})?;
if n < 0.0 {
return Err(format!("duration must not be negative: '{}'", input));
}
return Ok(Duration::from_secs_f64(n));
}
let mut total_secs: f64 = 0.0;
let mut remaining = input;
let mut found_any = false;
while !remaining.is_empty() {
let num_end = remaining
.bytes()
.position(|b| b.is_ascii_alphabetic())
.ok_or_else(|| format!("invalid duration '{}': trailing number without unit", input))?;
if num_end == 0 {
return Err(format!(
"invalid duration '{}': expected a number before unit",
input
));
}
let num_str = &remaining[..num_end];
let after_num = &remaining[num_end..];
let (multiplier, consumed) = if after_num.starts_with("ms") {
(0.001, 2)
} else if after_num.starts_with('h') {
(3600.0, 1)
} else if after_num.starts_with('m') {
(60.0, 1)
} else if after_num.starts_with('s') {
(1.0, 1)
} else {
return Err(format!(
"invalid duration '{}': unknown unit at '{}'",
input, after_num
));
};
let n: f64 = num_str
.parse()
.map_err(|_| format!("invalid duration '{}': bad number '{}'", input, num_str))?;
if n < 0.0 {
return Err(format!("duration must not be negative: '{}'", input));
}
total_secs += n * multiplier;
remaining = &after_num[consumed..];
found_any = true;
}
if !found_any {
return Err(format!(
"invalid duration '{}': expected a number with optional unit (ms, s, m, h)",
input
));
}
Ok(Duration::from_secs_f64(total_secs))
}
pub fn format_duration(d: Duration) -> String {
let total_ms = d.as_millis();
if total_ms == 0 {
return "0s".into();
}
let total_secs = d.as_secs();
let ms = d.subsec_millis();
let h = total_secs / 3600;
let m = (total_secs % 3600) / 60;
let s = total_secs % 60;
let mut parts = Vec::new();
if h > 0 {
parts.push(format!("{}h", h));
}
if m > 0 {
parts.push(format!("{}m", m));
}
if s > 0 {
parts.push(format!("{}s", s));
}
if ms > 0 {
parts.push(format!("{}ms", ms));
}
if parts.is_empty() {
"0s".into()
} else {
parts.join("")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_seconds_with_unit() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
assert_eq!(parse_duration("0.5s").unwrap(), Duration::from_millis(500));
}
#[test]
fn test_parse_milliseconds() {
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
assert_eq!(
parse_duration("1000ms").unwrap(),
Duration::from_millis(1000)
);
assert_eq!(parse_duration("100ms").unwrap(), Duration::from_millis(100));
}
#[test]
fn test_parse_minutes() {
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
}
#[test]
fn test_parse_hours() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
}
#[test]
fn test_parse_bare_number_defaults_to_seconds() {
assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("300").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("0").unwrap(), Duration::from_secs(0));
}
#[test]
fn test_parse_fractional() {
assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
assert_eq!(parse_duration("0.5m").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("2.5").unwrap(), Duration::from_millis(2500));
}
#[test]
fn test_parse_decimal_units() {
assert_eq!(parse_duration("1.5m").unwrap(), Duration::from_secs(90));
assert_eq!(
parse_duration("2.7s").unwrap(),
Duration::from_secs_f64(2.7)
);
assert_eq!(
parse_duration("18.6h").unwrap(),
Duration::from_secs_f64(18.6 * 3600.0)
);
assert_eq!(
parse_duration("0.5h").unwrap(),
Duration::from_secs(30 * 60)
);
}
#[test]
fn test_parse_combined_units() {
assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90));
assert_eq!(
parse_duration("2s700ms").unwrap(),
Duration::from_millis(2700)
);
assert_eq!(
parse_duration("18h36m4s200ms").unwrap(),
Duration::from_millis(18 * 3_600_000 + 36 * 60_000 + 4_000 + 200)
);
assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
assert_eq!(
parse_duration("2h0m30s").unwrap(),
Duration::from_secs(7230)
);
}
#[test]
fn test_parse_combined_with_decimals() {
assert_eq!(
parse_duration("1m30.5s").unwrap(),
Duration::from_secs_f64(90.5)
);
assert_eq!(parse_duration("1h0.5m").unwrap(), Duration::from_secs(3630));
}
#[test]
fn test_parse_whitespace_trimmed() {
assert_eq!(parse_duration(" 30s ").unwrap(), Duration::from_secs(30));
}
#[test]
fn test_parse_empty_error() {
assert!(parse_duration("").is_err());
}
#[test]
fn test_parse_invalid_errors() {
assert!(parse_duration("abc").is_err());
assert!(parse_duration("30x").is_err());
assert!(parse_duration("--5s").is_err());
}
#[test]
fn test_parse_negative_error() {
assert!(parse_duration("-5s").is_err());
assert!(parse_duration("-100ms").is_err());
assert!(parse_duration("-1").is_err());
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(Duration::from_secs(0)), "0s");
assert_eq!(format_duration(Duration::from_millis(500)), "500ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1s");
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
assert_eq!(format_duration(Duration::from_secs(60)), "1m");
assert_eq!(format_duration(Duration::from_secs(300)), "5m");
assert_eq!(format_duration(Duration::from_secs(3600)), "1h");
}
#[test]
fn test_format_duration_combined() {
assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
assert_eq!(format_duration(Duration::from_secs(5400)), "1h30m");
assert_eq!(format_duration(Duration::from_millis(2700)), "2s700ms");
assert_eq!(format_duration(Duration::from_secs(3661)), "1h1m1s");
assert_eq!(
format_duration(Duration::from_millis(3661500)),
"1h1m1s500ms"
);
}
}