use std::time::{SystemTime, UNIX_EPOCH};
use chrono::{DateTime, Local, Utc};
pub fn parse_duration_secs(s: &str) -> Result<u64, Box<dyn std::error::Error>> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration value".into());
}
let (num_str, mult) = match s.as_bytes().last().copied() {
Some(b's') => (&s[..s.len() - 1], 1u64),
Some(b'm') => (&s[..s.len() - 1], 60),
Some(b'h') => (&s[..s.len() - 1], 3600),
Some(b'd') => (&s[..s.len() - 1], 86_400),
Some(b'w') => (&s[..s.len() - 1], 604_800),
Some(c) if c.is_ascii_digit() => (s, 1),
_ => return Err(format!("invalid duration '{s}' (use N[s|m|h|d|w])").into()),
};
let n: u64 = num_str
.parse()
.map_err(|_| format!("invalid duration number in '{s}'"))?;
if n == 0 {
return Err("duration must be positive".into());
}
Ok(n.saturating_mul(mult))
}
pub fn duration_to_expires_at(s: &str) -> Result<u64, Box<dyn std::error::Error>> {
let secs = parse_duration_secs(s)?;
let now = now_unix();
Ok(now.saturating_add(secs))
}
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn format_local_time(unix_secs: u64) -> String {
match DateTime::from_timestamp(unix_secs as i64, 0) {
Some(utc) => format_local_datetime(utc),
None => unix_secs.to_string(),
}
}
pub fn format_local_datetime(dt: DateTime<Utc>) -> String {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
}
pub fn format_remaining(unix_secs: u64) -> String {
let now = now_unix();
let (secs, expired) = if unix_secs >= now {
(unix_secs - now, false)
} else {
(now - unix_secs, true)
};
let pretty = humanize_duration(secs);
if expired {
format!("expired {pretty} ago")
} else {
format!("in {pretty}")
}
}
pub fn humanize_duration(secs: u64) -> String {
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
if secs >= WEEK {
let weeks = secs / WEEK;
let days = (secs % WEEK) / DAY;
if days == 0 {
format!("{weeks}w")
} else {
format!("{weeks}w {days}d")
}
} else if secs >= DAY {
let days = secs / DAY;
let hours = (secs % DAY) / HOUR;
if hours == 0 {
format!("{days}d")
} else {
format!("{days}d {hours}h")
}
} else if secs >= HOUR {
let hours = secs / HOUR;
let mins = (secs % HOUR) / MIN;
if mins == 0 {
format!("{hours}h")
} else {
format!("{hours}h {mins}m")
}
} else if secs >= MIN {
let mins = secs / MIN;
let s = secs % MIN;
if s == 0 {
format!("{mins}m")
} else {
format!("{mins}m {s}s")
}
} else {
format!("{secs}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_unit() {
assert_eq!(parse_duration_secs("30s").unwrap(), 30);
assert_eq!(parse_duration_secs("5m").unwrap(), 300);
assert_eq!(parse_duration_secs("2h").unwrap(), 7200);
assert_eq!(parse_duration_secs("7d").unwrap(), 604_800);
assert_eq!(parse_duration_secs("2w").unwrap(), 1_209_600);
assert_eq!(parse_duration_secs("3600").unwrap(), 3600);
assert_eq!(parse_duration_secs(" 24h ").unwrap(), 86_400);
}
#[test]
fn rejects_garbage() {
assert!(parse_duration_secs("").is_err());
assert!(parse_duration_secs("abc").is_err());
assert!(parse_duration_secs("7x").is_err());
assert!(parse_duration_secs("0h").is_err());
}
#[test]
fn expiry_is_in_the_future() {
let before = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let expiry = duration_to_expires_at("60s").unwrap();
assert!(expiry >= before + 60);
assert!(expiry <= before + 65);
}
#[test]
fn humanize_picks_the_right_unit_pair() {
assert_eq!(humanize_duration(0), "0s");
assert_eq!(humanize_duration(45), "45s");
assert_eq!(humanize_duration(60), "1m");
assert_eq!(humanize_duration(125), "2m 5s");
assert_eq!(humanize_duration(3600), "1h");
assert_eq!(humanize_duration(3660), "1h 1m");
assert_eq!(humanize_duration(86400), "1d");
assert_eq!(humanize_duration(90000), "1d 1h");
assert_eq!(humanize_duration(604_800), "1w");
assert_eq!(humanize_duration(691_200), "1w 1d");
}
#[test]
fn format_remaining_handles_future_and_past() {
let now = now_unix();
assert!(format_remaining(now + 3600).starts_with("in "));
assert!(format_remaining(now - 3600).starts_with("expired "));
assert!(format_remaining(now - 3600).ends_with("ago"));
}
#[test]
fn format_local_time_is_nonempty() {
let s = format_local_time(1776600737);
assert!(s.len() > 10, "unexpectedly short: {s}");
assert!(s.contains(':'));
}
}