mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-06 03:16:46 -06:00
327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
//! Human-readable duration parsing with support for mixed time units.
|
|
//!
|
|
//! Parses strings like "1y2mon3w4d5h6m7s", "+1year", or "-3h30m" into chrono Duration objects.
|
|
//! Time units can appear in any order and use various abbreviations.
|
|
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use lazy_static::lazy_static;
|
|
use regex::Regex;
|
|
|
|
use crate::error::TimeBannerError;
|
|
|
|
/// Extends chrono::Duration with month support using approximate calendar math.
|
|
pub trait Months {
|
|
fn months(count: i32) -> Self;
|
|
}
|
|
|
|
impl Months for Duration {
|
|
/// Creates a duration representing the given number of months.
|
|
/// Uses 365.25/12 ≈ 30.44 days per month for approximation.
|
|
fn months(count: i32) -> Self {
|
|
Duration::milliseconds(
|
|
(Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64,
|
|
) * count
|
|
}
|
|
}
|
|
|
|
lazy_static! {
|
|
/// Regex pattern matching duration strings with flexible ordering and abbreviations.
|
|
///
|
|
/// Supports:
|
|
/// - Optional +/- sign
|
|
/// - Years: y, yr, yrs, year, years
|
|
/// - Months: mon, month, months
|
|
/// - Weeks: w, wk, wks, week, weeks
|
|
/// - Days: d, day, days
|
|
/// - Hours: h, hr, hrs, hour, hours
|
|
/// - Minutes: m, min, mins, minute, minutes
|
|
/// - Seconds: s, sec, secs, second, seconds
|
|
///
|
|
/// Time units must appear in descending order of magnitude, e.g. "1y2d" is valid, "1d2y" is not.
|
|
static ref FULL_RELATIVE_PATTERN: Regex = Regex::new(concat!(
|
|
"(?<sign>[-+])?",
|
|
r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?",
|
|
r"(?:(?<month>\d+)\s?(?:months?|mon)\s*)?",
|
|
r"(?:(?<week>\d+)\s?(?:weeks?|wks?|w)\s*)?",
|
|
r"(?:(?<day>\d+)\s?(?:days?|d)\s*)?",
|
|
r"(?:(?<hour>\d+)\s?(?:hours?|hrs?|h)\s*)?",
|
|
r"(?:(?<minute>\d+)\s?(?:minutes?|mins?|m)\s*)?",
|
|
r"(?:(?<second>\d+)\s?(?:seconds?|secs?|s)\s*)?"
|
|
))
|
|
.unwrap();
|
|
}
|
|
|
|
/// Parses a human-readable duration string into a chrono Duration.
|
|
///
|
|
/// Examples:
|
|
/// - `"1y2d"` → 1 year + 2 days
|
|
/// - `"+3h30m"` → +3.5 hours
|
|
/// - `"-1week"` → -7 days
|
|
/// - `"2months4days"` → ~2.03 months
|
|
///
|
|
/// Years include leap year compensation (+6 hours per year).
|
|
/// Empty strings return zero duration.
|
|
pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
|
let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap();
|
|
let mut value = Duration::zero();
|
|
|
|
if let Some(raw_year) = capture.name("year") {
|
|
value += match raw_year.as_str().parse::<i64>() {
|
|
Ok(year) => {
|
|
Duration::days(year * 365)
|
|
+ (if year > 0 {
|
|
Duration::hours(6) * year as i32 // Leap year compensation
|
|
} else {
|
|
Duration::zero()
|
|
})
|
|
}
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse year from {} ({})",
|
|
raw_year.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_month) = capture.name("month") {
|
|
value += match raw_month.as_str().parse::<i32>() {
|
|
Ok(month) => Duration::months(month),
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse month from {} ({})",
|
|
raw_month.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_week) = capture.name("week") {
|
|
value += match raw_week.as_str().parse::<i64>() {
|
|
Ok(week) => Duration::days(7) * week as i32,
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse week from {} ({})",
|
|
raw_week.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_day) = capture.name("day") {
|
|
value += match raw_day.as_str().parse::<i64>() {
|
|
Ok(day) => Duration::days(day),
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse day from {} ({})",
|
|
raw_day.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_hour) = capture.name("hour") {
|
|
value += match raw_hour.as_str().parse::<i64>() {
|
|
Ok(hour) => Duration::hours(hour),
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse hour from {} ({})",
|
|
raw_hour.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_minute) = capture.name("minute") {
|
|
value += match raw_minute.as_str().parse::<i64>() {
|
|
Ok(minute) => Duration::minutes(minute),
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse minute from {} ({})",
|
|
raw_minute.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_second) = capture.name("second") {
|
|
value += match raw_second.as_str().parse::<i64>() {
|
|
Ok(second) => Duration::seconds(second),
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Could not parse second from {} ({})",
|
|
raw_second.as_str(),
|
|
e
|
|
))
|
|
}
|
|
};
|
|
}
|
|
|
|
if let Some(raw_sign) = capture.name("sign") {
|
|
match raw_sign.as_str() {
|
|
"-" => value = -value,
|
|
"+" => (),
|
|
_ => return Err(format!("Could not parse sign from {}", raw_sign.as_str())),
|
|
};
|
|
}
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
/// Converts Unix epoch timestamp to UTC DateTime.
|
|
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
|
|
DateTime::from_timestamp(epoch, 0)
|
|
}
|
|
|
|
/// Parses various time value formats into a UTC datetime.
|
|
///
|
|
/// Supports:
|
|
/// - Relative offsets: "+3600", "-1800" (seconds from now)
|
|
/// - Duration strings: "+1y2d", "-3h30m" (using duration parser)
|
|
/// - Epoch timestamps: "1752170474" (Unix timestamp)
|
|
pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
|
|
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
|
|
if raw_time.starts_with('+') || raw_time.starts_with('-') {
|
|
let now = Utc::now();
|
|
|
|
// Try parsing as simple offset seconds first
|
|
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
|
|
return Ok(now + Duration::seconds(offset_seconds));
|
|
}
|
|
|
|
// Try parsing as duration string (e.g., "+1y2d", "-3h30m")
|
|
if let Ok(duration) = parse_duration(raw_time) {
|
|
return Ok(now + duration);
|
|
}
|
|
|
|
return Err(TimeBannerError::ParseError(format!(
|
|
"Could not parse relative time: {}",
|
|
raw_time
|
|
)));
|
|
}
|
|
|
|
// Try to parse as epoch timestamp
|
|
if let Ok(epoch) = raw_time.parse::<i64>() {
|
|
return parse_epoch_into_datetime(epoch)
|
|
.ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string()));
|
|
}
|
|
|
|
Err(TimeBannerError::ParseError(format!(
|
|
"Could not parse time value: {}",
|
|
raw_time
|
|
)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::duration::{parse_duration, Months};
|
|
use chrono::Duration;
|
|
|
|
#[test]
|
|
fn parse_empty() {
|
|
assert_eq!(parse_duration(""), Ok(Duration::zero()));
|
|
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
|
|
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_composite() {
|
|
assert_eq!(
|
|
parse_duration("1y2mon3w4d5h6m7s"),
|
|
Ok(Duration::days(365)
|
|
+ Duration::hours(6) // leap year compensation
|
|
+ Duration::months(2)
|
|
+ Duration::weeks(3)
|
|
+ Duration::days(4)
|
|
+ Duration::hours(5)
|
|
+ Duration::minutes(6)
|
|
+ Duration::seconds(7)),
|
|
"1y2mon3w4d5h6m7s"
|
|
);
|
|
assert_eq!(
|
|
parse_duration("19year33weeks4d9min"),
|
|
Ok(Duration::days(365 * 19)
|
|
+ Duration::hours(6 * 19)
|
|
+ Duration::days(33 * 7 + 4)
|
|
+ Duration::minutes(9)),
|
|
"19year33weeks4d9min"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_year() {
|
|
assert_eq!(
|
|
parse_duration("1y"),
|
|
Ok(Duration::days(365) + Duration::hours(6))
|
|
);
|
|
assert_eq!(
|
|
parse_duration("2year"),
|
|
Ok(Duration::days(365 * 2) + Duration::hours(6 * 2))
|
|
);
|
|
assert_eq!(
|
|
parse_duration("144years"),
|
|
Ok(Duration::days(365 * 144) + Duration::hours(6 * 144))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_month() {
|
|
assert_eq!(Duration::zero(), parse_duration("0mon").unwrap());
|
|
assert_eq!(Duration::months(3), parse_duration("3mon").unwrap());
|
|
assert_eq!(Duration::months(-14), parse_duration("-14mon").unwrap());
|
|
assert_eq!(Duration::months(144), parse_duration("+144months").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_week() {
|
|
assert_eq!(Duration::zero(), parse_duration("0w").unwrap());
|
|
assert_eq!(Duration::weeks(7), parse_duration("7w").unwrap());
|
|
assert_eq!(Duration::weeks(19), parse_duration("19week").unwrap());
|
|
assert_eq!(Duration::weeks(433), parse_duration("433weeks").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_day() {
|
|
assert_eq!(Duration::zero(), parse_duration("0d").unwrap());
|
|
assert_eq!(Duration::days(9), parse_duration("9d").unwrap());
|
|
assert_eq!(Duration::days(43), parse_duration("43day").unwrap());
|
|
assert_eq!(Duration::days(969), parse_duration("969days").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_hour() {
|
|
assert_eq!(Duration::zero(), parse_duration("0h").unwrap());
|
|
assert_eq!(Duration::hours(4), parse_duration("4h").unwrap());
|
|
assert_eq!(Duration::hours(150), parse_duration("150hour").unwrap());
|
|
assert_eq!(Duration::hours(777), parse_duration("777hours").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_minute() {
|
|
assert_eq!(Duration::zero(), parse_duration("0m").unwrap());
|
|
assert_eq!(Duration::minutes(5), parse_duration("5m").unwrap());
|
|
assert_eq!(Duration::minutes(60), parse_duration("60min").unwrap());
|
|
assert_eq!(
|
|
Duration::minutes(999),
|
|
parse_duration("999minutes").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_second() {
|
|
assert_eq!(Duration::zero(), parse_duration("0s").unwrap());
|
|
assert_eq!(Duration::seconds(6), parse_duration("6s").unwrap());
|
|
assert_eq!(Duration::minutes(1), parse_duration("60sec").unwrap());
|
|
assert_eq!(
|
|
Duration::seconds(999),
|
|
parse_duration("999seconds").unwrap()
|
|
);
|
|
}
|
|
}
|