use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
use thiserror::Error;
#[derive(Debug, Clone, Error, PartialEq)]
pub enum DateRangeParseError {
#[error("Invalid date format: {0}")]
InvalidDateFormat(String),
#[error("Invalid range format: {0}")]
InvalidRangeFormat(String),
#[error("Invalid number: {0}")]
InvalidNumber(String),
#[error("Invalid time unit: {0}")]
InvalidTimeUnit(String),
#[error("Start date must be before end date")]
InvalidDateOrder,
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
}
type DateRangeResult = Result<Option<(DateTime<Utc>, DateTime<Utc>)>, DateRangeParseError>;
pub fn parse_date_range(input: &str) -> Result<(i64, i64), DateRangeParseError> {
let trimmed = input.trim();
if let Some((start, end)) = try_parse_preset(trimmed)? {
return Ok((start.timestamp(), end.timestamp()));
}
if let Some((start, end)) = try_parse_relative(trimmed)? {
return Ok((start.timestamp(), end.timestamp()));
}
if let Some((start, end)) = try_parse_absolute(trimmed)? {
return Ok((start.timestamp(), end.timestamp()));
}
Err(DateRangeParseError::UnsupportedFormat(trimmed.to_string()))
}
fn try_parse_preset(s: &str) -> DateRangeResult {
let upper = s.to_uppercase();
let now = Utc::now();
let duration = match upper.as_str() {
"YTD" => {
let year_start = NaiveDate::from_ymd_opt(now.year(), 1, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.ok_or_else(|| DateRangeParseError::InvalidDateFormat("YTD".to_string()))?;
let start = Utc.from_utc_datetime(&year_start);
return Ok(Some((start, now)));
}
"1D" => Duration::days(1),
"5D" => Duration::days(5),
"1W" => Duration::weeks(1),
"1M" | "MTD" => Duration::days(30),
"3M" => Duration::days(90),
"6M" => Duration::days(180),
"1Y" => Duration::days(365),
"5Y" => Duration::days(365 * 5),
"10Y" => Duration::days(365 * 10),
"MAX" | "ALL" => Duration::days(365 * 20), _ => return Ok(None), };
let start = now - duration;
Ok(Some((start, now)))
}
fn try_parse_relative(s: &str) -> DateRangeResult {
let lower = s.to_lowercase();
if !lower.starts_with("last ") {
return Ok(None);
}
let parts: Vec<&str> = lower.split_whitespace().collect();
if parts.len() != 3 {
return Err(DateRangeParseError::InvalidRangeFormat(s.to_string()));
}
let n: i64 = parts[1]
.parse()
.map_err(|_| DateRangeParseError::InvalidNumber(parts[1].to_string()))?;
let unit = parts[2];
let now = Utc::now();
let duration = match unit {
"day" | "days" => Duration::days(n),
"week" | "weeks" => Duration::weeks(n),
"month" | "months" => Duration::days(n * 30), "year" | "years" => Duration::days(n * 365), _ => return Err(DateRangeParseError::InvalidTimeUnit(unit.to_string())),
};
let start = now - duration;
Ok(Some((start, now)))
}
fn try_parse_absolute(s: &str) -> DateRangeResult {
let separator = if s.contains(" to ") {
" to "
} else if s.contains("..") {
".."
} else if s.contains(" - ") {
" - "
} else {
return Ok(None); };
let parts: Vec<&str> = s.split(separator).collect();
if parts.len() != 2 {
return Err(DateRangeParseError::InvalidRangeFormat(s.to_string()));
}
let start = parse_date_string(parts[0].trim())?;
let end = parse_date_string(parts[1].trim())?;
if start >= end {
return Err(DateRangeParseError::InvalidDateOrder);
}
Ok(Some((start, end)))
}
fn parse_date_string(s: &str) -> Result<DateTime<Utc>, DateRangeParseError> {
let s_normalized = s.replace('/', "-");
let parts: Vec<&str> = s_normalized.split('-').collect();
if parts.len() != 3 {
return Err(DateRangeParseError::InvalidDateFormat(s.to_string()));
}
let year: i32 = parts[0]
.parse()
.map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
let naive_date = NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
let naive_datetime = naive_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
Ok(Utc.from_utc_datetime(&naive_datetime))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preset_ytd() {
let result = parse_date_range("YTD");
assert!(result.is_ok());
let (start, _end) = result.unwrap();
let start_dt = Utc.timestamp_opt(start, 0).unwrap();
assert_eq!(start_dt.month(), 1);
assert_eq!(start_dt.day(), 1);
}
#[test]
fn test_preset_1m() {
let result = parse_date_range("1M");
assert!(result.is_ok());
}
#[test]
fn test_preset_case_insensitive() {
assert!(parse_date_range("ytd").is_ok());
assert!(parse_date_range("1m").is_ok());
assert!(parse_date_range("MAX").is_ok());
}
#[test]
fn test_relative_days() {
let result = parse_date_range("last 30 days");
assert!(result.is_ok());
}
#[test]
fn test_relative_months() {
let result = parse_date_range("last 6 months");
assert!(result.is_ok());
}
#[test]
fn test_relative_years() {
let result = parse_date_range("last 1 year");
assert!(result.is_ok());
}
#[test]
fn test_absolute_to() {
let result = parse_date_range("2024-01-01 to 2024-12-31");
assert!(result.is_ok());
let (start, end) = result.unwrap();
let start_dt = Utc.timestamp_opt(start, 0).unwrap();
let end_dt = Utc.timestamp_opt(end, 0).unwrap();
assert_eq!(start_dt.year(), 2024);
assert_eq!(start_dt.month(), 1);
assert_eq!(start_dt.day(), 1);
assert_eq!(end_dt.year(), 2024);
assert_eq!(end_dt.month(), 12);
assert_eq!(end_dt.day(), 31);
}
#[test]
fn test_absolute_dotdot() {
let result = parse_date_range("2024-01-01..2024-06-30");
assert!(result.is_ok());
}
#[test]
fn test_absolute_slash() {
let result = parse_date_range("2024/01/01 to 2024/06/30");
assert!(result.is_ok());
}
#[test]
fn test_invalid_order() {
let result = parse_date_range("2024-12-31 to 2024-01-01");
assert!(matches!(result, Err(DateRangeParseError::InvalidDateOrder)));
}
#[test]
fn test_invalid_format() {
let result = parse_date_range("invalid");
assert!(matches!(
result,
Err(DateRangeParseError::UnsupportedFormat(_))
));
}
#[test]
fn test_invalid_date() {
let result = parse_date_range("2024-13-01 to 2024-12-31");
assert!(matches!(
result,
Err(DateRangeParseError::InvalidDateFormat(_))
));
}
}