use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DateParseError {
#[error(
"Could not parse date: '{0}'. Try formats like 'tomorrow', '2025-01-15', or 'in 3 days'."
)]
InvalidFormat(String),
#[error("Invalid timezone: '{0}'")]
#[allow(dead_code)] InvalidTimezone(String),
#[error("Date is in the past: '{0}'")]
#[allow(dead_code)] PastDate(String),
}
pub fn parse_date(input: &str) -> Result<DateTime<Utc>, DateParseError> {
let input = input.trim();
let input_lower = input.to_lowercase();
if input.is_empty() {
return Err(DateParseError::InvalidFormat("empty string".to_string()));
}
let now = Utc::now();
let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
if input_lower == "today" {
return Ok(today_start);
}
if input_lower == "tomorrow" {
return Ok(today_start + Duration::days(1));
}
if input_lower == "yesterday" {
return Ok(today_start - Duration::days(1));
}
if input_lower == "next week" {
return Ok(today_start + Duration::weeks(1));
}
if input_lower == "next month" {
return Ok(today_start + Duration::days(30));
}
if let Some(rest) = input_lower.strip_prefix("in ") {
if let Some(result) = parse_relative_time(rest, now) {
return Ok(result);
}
}
dateparser::parse(input).map_err(|_| DateParseError::InvalidFormat(input.to_string()))
}
fn parse_relative_time(input: &str, base: DateTime<Utc>) -> Option<DateTime<Utc>> {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let amount: i64 = parts[0].parse().ok()?;
let unit = parts[1].to_lowercase();
match unit.as_str() {
"day" | "days" => Some(base + Duration::days(amount)),
"week" | "weeks" => Some(base + Duration::weeks(amount)),
"hour" | "hours" => Some(base + Duration::hours(amount)),
"minute" | "minutes" | "min" | "mins" => Some(base + Duration::minutes(amount)),
"month" | "months" => Some(base + Duration::days(amount * 30)),
_ => None,
}
}
#[allow(dead_code)] pub fn parse_date_with_timezone(
input: &str,
timezone: &str,
) -> Result<DateTime<Utc>, DateParseError> {
let tz: Tz = timezone
.parse()
.map_err(|_| DateParseError::InvalidTimezone(timezone.to_string()))?;
let input = input.trim();
if let Ok(dt) = dateparser::parse(input) {
return Ok(dt);
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d") {
let naive_dt = date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let local_dt = tz
.from_local_datetime(&naive_dt)
.single()
.ok_or_else(|| DateParseError::InvalidFormat(input.to_string()))?;
return Ok(local_dt.with_timezone(&Utc));
}
Err(DateParseError::InvalidFormat(input.to_string()))
}
#[allow(dead_code)] pub fn parse_future_date(input: &str) -> Result<DateTime<Utc>, DateParseError> {
let date = parse_date(input)?;
if date < Utc::now() {
return Err(DateParseError::PastDate(input.to_string()));
}
Ok(date)
}
#[allow(dead_code)] pub fn local_timezone() -> String {
if let Ok(tz) = std::env::var("TZ") {
return tz;
}
Local::now().format("%Z").to_string()
}
#[allow(dead_code)] pub fn format_datetime(dt: &DateTime<Utc>, timezone: Option<&str>) -> String {
if let Some(tz_str) = timezone {
if let Ok(tz) = tz_str.parse::<Tz>() {
let local_dt = dt.with_timezone(&tz);
return local_dt.format("%Y-%m-%d %H:%M:%S %Z").to_string();
}
}
dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_iso_date() {
let result = parse_date("2030-06-15T00:00:00Z");
assert!(result.is_ok());
let dt = result.unwrap();
assert_eq!(dt.date_naive().to_string(), "2030-06-15");
}
#[test]
fn test_parse_iso_datetime() {
let result = parse_date("2025-01-15T14:30:00Z");
assert!(result.is_ok());
let dt = result.unwrap();
assert_eq!(
dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
"2025-01-15T14:30:00"
);
}
#[test]
fn test_parse_natural_language_today() {
let result = parse_date("today");
assert!(result.is_ok());
let dt = result.unwrap();
let today = Utc::now().date_naive();
assert_eq!(dt.date_naive(), today);
}
#[test]
fn test_parse_natural_language_tomorrow() {
let result = parse_date("tomorrow");
assert!(result.is_ok());
let dt = result.unwrap();
let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
assert_eq!(dt.date_naive(), tomorrow);
}
#[test]
fn test_parse_relative_in_days() {
let result = parse_date("in 3 days");
assert!(result.is_ok());
let dt = result.unwrap();
let expected = Utc::now().date_naive() + chrono::Duration::days(3);
assert_eq!(dt.date_naive(), expected);
}
#[test]
fn test_parse_empty_string() {
let result = parse_date("");
assert!(result.is_err());
match result {
Err(DateParseError::InvalidFormat(s)) => assert_eq!(s, "empty string"),
_ => panic!("Expected InvalidFormat error"),
}
}
#[test]
fn test_parse_invalid_string() {
let result = parse_date("not a date at all xyz");
assert!(result.is_err());
}
#[test]
fn test_parse_with_timezone() {
let result = parse_date_with_timezone("2025-01-15", "America/New_York");
assert!(result.is_ok());
}
#[test]
fn test_parse_invalid_timezone() {
let result = parse_date_with_timezone("2025-01-15", "Invalid/Timezone");
assert!(result.is_err());
match result {
Err(DateParseError::InvalidTimezone(tz)) => assert_eq!(tz, "Invalid/Timezone"),
_ => panic!("Expected InvalidTimezone error"),
}
}
#[test]
fn test_format_datetime_utc() {
let dt = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
let formatted = format_datetime(&dt, None);
assert_eq!(formatted, "2025-01-15 14:30:00 UTC");
}
#[test]
fn test_format_datetime_with_timezone() {
let dt = Utc.with_ymd_and_hms(2025, 1, 15, 19, 30, 0).unwrap();
let formatted = format_datetime(&dt, Some("America/New_York"));
assert!(formatted.contains("2025-01-15"));
assert!(formatted.contains("14:30:00"));
}
#[test]
fn test_local_timezone() {
let tz = local_timezone();
assert!(!tz.is_empty());
}
#[test]
fn test_date_parse_error_display() {
let err = DateParseError::InvalidFormat("bad date".to_string());
assert!(err.to_string().contains("bad date"));
assert!(err.to_string().contains("Try formats like"));
let err = DateParseError::InvalidTimezone("Bad/TZ".to_string());
assert!(err.to_string().contains("Bad/TZ"));
let err = DateParseError::PastDate("yesterday".to_string());
assert!(err.to_string().contains("past"));
}
#[test]
fn test_parse_yesterday() {
let result = parse_date("yesterday");
assert!(result.is_ok());
let dt = result.unwrap();
let yesterday = Utc::now().date_naive() - chrono::Duration::days(1);
assert_eq!(dt.date_naive(), yesterday);
}
#[test]
fn test_parse_next_week() {
let result = parse_date("next week");
assert!(result.is_ok());
let dt = result.unwrap();
let next_week = Utc::now().date_naive() + chrono::Duration::weeks(1);
assert_eq!(dt.date_naive(), next_week);
}
#[test]
fn test_parse_next_month() {
let result = parse_date("next month");
assert!(result.is_ok());
let dt = result.unwrap();
let next_month = Utc::now().date_naive() + chrono::Duration::days(30);
assert_eq!(dt.date_naive(), next_month);
}
#[test]
fn test_parse_in_hours() {
let before = Utc::now();
let result = parse_date("in 2 hours");
assert!(result.is_ok());
let dt = result.unwrap();
let diff = dt - before;
assert!(diff.num_hours() >= 1 && diff.num_hours() <= 2);
}
#[test]
fn test_parse_in_minutes() {
let before = Utc::now();
let result = parse_date("in 30 minutes");
assert!(result.is_ok());
let dt = result.unwrap();
let diff = dt - before;
assert!(diff.num_minutes() >= 29 && diff.num_minutes() <= 30);
}
#[test]
fn test_parse_in_weeks() {
let result = parse_date("in 2 weeks");
assert!(result.is_ok());
let dt = result.unwrap();
let expected = Utc::now().date_naive() + chrono::Duration::weeks(2);
assert_eq!(dt.date_naive(), expected);
}
#[test]
fn test_parse_in_months() {
let result = parse_date("in 3 months");
assert!(result.is_ok());
let dt = result.unwrap();
let expected = Utc::now().date_naive() + chrono::Duration::days(90);
assert_eq!(dt.date_naive(), expected);
}
#[test]
fn test_parse_case_insensitive() {
assert!(parse_date("TODAY").is_ok());
assert!(parse_date("Today").is_ok());
assert!(parse_date("TOMORROW").is_ok());
assert!(parse_date("Tomorrow").is_ok());
assert!(parse_date("IN 3 DAYS").is_ok());
assert!(parse_date("In 3 Days").is_ok());
}
#[test]
fn test_parse_whitespace_handling() {
let result = parse_date(" tomorrow ");
assert!(result.is_ok());
let dt = result.unwrap();
let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
assert_eq!(dt.date_naive(), tomorrow);
}
#[test]
fn test_parse_singular_units() {
assert!(parse_date("in 1 day").is_ok());
assert!(parse_date("in 1 week").is_ok());
assert!(parse_date("in 1 hour").is_ok());
assert!(parse_date("in 1 minute").is_ok());
assert!(parse_date("in 1 month").is_ok());
}
#[test]
fn test_parse_min_abbreviation() {
let before = Utc::now();
let result = parse_date("in 15 min");
assert!(result.is_ok());
let dt = result.unwrap();
let diff = dt - before;
assert!(diff.num_minutes() >= 14 && diff.num_minutes() <= 15);
let result = parse_date("in 15 mins");
assert!(result.is_ok());
}
#[test]
fn test_parse_future_date_valid() {
let result = parse_future_date("in 30 days");
assert!(result.is_ok());
}
#[test]
fn test_parse_future_date_past() {
let result = parse_future_date("yesterday");
assert!(result.is_err());
match result {
Err(DateParseError::PastDate(_)) => {}
_ => panic!("Expected PastDate error"),
}
}
#[test]
fn test_parse_date_with_timezone_datetime() {
let result = parse_date_with_timezone("2025-06-15T14:30:00Z", "America/Los_Angeles");
assert!(result.is_ok());
}
#[test]
fn test_format_datetime_invalid_timezone_fallback() {
let dt = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap();
let formatted = format_datetime(&dt, Some("Invalid/TZ"));
assert_eq!(formatted, "2025-01-15 14:30:00 UTC");
}
#[test]
fn test_parse_incomplete_relative_time() {
let result = parse_date("in 3");
assert!(result.is_err());
}
#[test]
fn test_parse_invalid_relative_unit() {
let result = parse_date("in 3 foobar");
assert!(result.is_err());
}
}