use std::time::{Duration, SystemTime};
use anyhow::{Result, bail};
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const MONTH: u64 = 30 * DAY;
const YEAR: u64 = 365 * DAY;
pub fn parse_duration(input: &str) -> Result<Duration> {
let text = input.trim().to_ascii_lowercase();
if text.is_empty() {
bail!("empty duration");
}
if matches!(text.as_str(), "0" | "off" | "any" | "none") {
return Ok(Duration::ZERO);
}
let mut total: u64 = 0;
let mut number: Option<u64> = None;
let mut unit = String::new();
let mut saw_any = false;
for ch in text.chars() {
if ch.is_ascii_digit() {
if !unit.is_empty() {
total += flush(number.take(), &unit, &text)?;
unit.clear();
}
number = Some(
number
.unwrap_or(0)
.checked_mul(10)
.and_then(|n| n.checked_add(u64::from(ch as u8 - b'0')))
.ok_or_else(|| anyhow::anyhow!("duration {input:?} is too large"))?,
);
saw_any = true;
} else if ch.is_ascii_alphabetic() {
unit.push(ch);
} else if ch == ' ' || ch == '_' {
continue;
} else {
bail!("unexpected character {ch:?} in duration {input:?}");
}
}
if !saw_any {
bail!("no number in duration {input:?}");
}
total += flush(number, &unit, &text)?;
Ok(Duration::from_secs(total))
}
fn flush(number: Option<u64>, unit: &str, whole: &str) -> Result<u64> {
let Some(number) = number else {
if unit.is_empty() {
return Ok(0);
}
bail!("unit {unit:?} without a number in {whole:?}");
};
let seconds = match unit {
"" | "d" | "day" | "days" => DAY,
"s" | "sec" | "secs" | "second" | "seconds" => 1,
"m" | "min" | "mins" | "minute" | "minutes" => MINUTE,
"h" | "hr" | "hrs" | "hour" | "hours" => HOUR,
"w" | "week" | "weeks" => WEEK,
"mo" | "mon" | "month" | "months" => MONTH,
"y" | "yr" | "year" | "years" => YEAR,
other => bail!("unknown duration unit {other:?} in {whole:?}"),
};
number
.checked_mul(seconds)
.ok_or_else(|| anyhow::anyhow!("duration {whole:?} is too large"))
}
pub fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs == 0 {
return "off".to_string();
}
for (unit, size) in [
("y", YEAR),
("mo", MONTH),
("w", WEEK),
("d", DAY),
("h", HOUR),
] {
if secs >= size && secs.is_multiple_of(size) {
return format!("{}{unit}", secs / size);
}
}
format!("{}s", secs)
}
pub fn format_age(age: Duration) -> String {
let secs = age.as_secs();
let plural = |n: u64, word: &str| {
if n == 1 {
format!("1 {word}")
} else {
format!("{n} {word}s")
}
};
match secs {
0..=59 => "just now".to_string(),
s if s < HOUR => plural(s / MINUTE, "minute"),
s if s < DAY => plural(s / HOUR, "hour"),
s if s < WEEK * 2 => plural(s / DAY, "day"),
s if s < MONTH * 2 => plural(s / WEEK, "week"),
s if s < YEAR * 2 => plural(s / MONTH, "month"),
s => plural(s / YEAR, "year"),
}
}
pub fn age_of(t: SystemTime) -> Duration {
SystemTime::now()
.duration_since(t)
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_units() {
assert_eq!(
parse_duration("30d").unwrap(),
Duration::from_secs(30 * DAY)
);
assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90 * DAY));
assert_eq!(
parse_duration("12h").unwrap(),
Duration::from_secs(12 * HOUR)
);
assert_eq!(parse_duration("2w").unwrap(), Duration::from_secs(2 * WEEK));
assert_eq!(
parse_duration("6mo").unwrap(),
Duration::from_secs(6 * MONTH)
);
assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
assert_eq!(parse_duration("off").unwrap(), Duration::ZERO);
}
#[test]
fn parses_compound() {
assert_eq!(
parse_duration("1y6mo").unwrap(),
Duration::from_secs(YEAR + 6 * MONTH)
);
assert_eq!(
parse_duration("1w 3d").unwrap(),
Duration::from_secs(WEEK + 3 * DAY)
);
}
#[test]
fn rejects_garbage() {
assert!(parse_duration("").is_err());
assert!(parse_duration("d").is_err());
assert!(parse_duration("10x").is_err());
assert!(parse_duration("abc").is_err());
}
#[test]
fn formats_duration_with_the_coarsest_exact_unit() {
assert_eq!(format_duration(Duration::ZERO), "off");
assert_eq!(format_duration(parse_duration("1y").unwrap()), "1y");
assert_eq!(format_duration(parse_duration("6mo").unwrap()), "6mo");
assert_eq!(format_duration(parse_duration("7d").unwrap()), "1w");
assert_eq!(format_duration(parse_duration("10d").unwrap()), "10d");
}
#[test]
fn formats_age() {
assert_eq!(format_age(Duration::from_secs(30)), "just now");
assert_eq!(format_age(Duration::from_secs(3 * DAY)), "3 days");
assert_eq!(format_age(Duration::from_secs(5 * MONTH)), "5 months");
assert_eq!(format_age(Duration::from_secs(3 * YEAR)), "3 years");
}
}