//! 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!( "(?[-+])?", r"(?:(?\d+)\s?(?:years?|yrs?|y)\s*)?", r"(?:(?\d+)\s?(?:months?|mon)\s*)?", r"(?:(?\d+)\s?(?:weeks?|wks?|w)\s*)?", r"(?:(?\d+)\s?(?:days?|d)\s*)?", r"(?:(?\d+)\s?(?:hours?|hrs?|h)\s*)?", r"(?:(?\d+)\s?(?:minutes?|mins?|m)\s*)?", r"(?:(?\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 { 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::() { 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::() { 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::() { 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::() { 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::() { 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::() { 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::() { 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::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, 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::() { 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::() { 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() ); } }