use chrono::{DateTime, Duration, NaiveDate, Utc};
#[derive(Debug, Clone)]
pub enum ParsedTime {
DateTime(DateTime<Utc>),
DateOnly(NaiveDate),
}
pub fn parse_time(input: &str) -> Result<ParsedTime, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("empty time expression".to_string());
}
let lower = trimmed.to_lowercase();
match lower.as_str() {
"now" => return Ok(ParsedTime::DateTime(Utc::now())),
"today" => {
let today = chrono::Local::now().date_naive();
return Ok(ParsedTime::DateOnly(today));
}
"tomorrow" => {
let tomorrow = chrono::Local::now().date_naive() + Duration::days(1);
return Ok(ParsedTime::DateOnly(tomorrow));
}
"yesterday" => {
let yesterday = chrono::Local::now().date_naive() - Duration::days(1);
return Ok(ParsedTime::DateOnly(yesterday));
}
"next week" => {
let next_week = chrono::Local::now().date_naive() + Duration::weeks(1);
return Ok(ParsedTime::DateOnly(next_week));
}
"last week" => {
let last_week = chrono::Local::now().date_naive() - Duration::weeks(1);
return Ok(ParsedTime::DateOnly(last_week));
}
_ => {}
}
if let Some(dt) = parse_relative_past(&lower) {
return Ok(ParsedTime::DateTime(dt));
}
if let Some(dt) = parse_relative_future(&lower) {
return Ok(ParsedTime::DateTime(dt));
}
if let Ok(dt) = trimmed.parse::<DateTime<Utc>>() {
return Ok(ParsedTime::DateTime(dt));
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
return Ok(ParsedTime::DateTime(dt.with_timezone(&Utc)));
}
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Ok(ParsedTime::DateOnly(d));
}
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%m/%d/%Y") {
return Ok(ParsedTime::DateOnly(d));
}
Err(format!("unrecognized time expression: {:?}", input))
}
fn parse_relative_past(lower: &str) -> Option<DateTime<Utc>> {
let lower = lower.trim();
let rest = lower.strip_suffix(" ago")?;
let (n, unit) = split_n_unit(rest)?;
let now = Utc::now();
let dt = apply_offset(now, -n, unit)?;
Some(dt)
}
fn parse_relative_future(lower: &str) -> Option<DateTime<Utc>> {
let lower = lower.trim();
let rest = lower.strip_prefix("in ")?;
let (n, unit) = split_n_unit(rest)?;
let now = Utc::now();
let dt = apply_offset(now, n, unit)?;
Some(dt)
}
fn split_n_unit(s: &str) -> Option<(i64, &str)> {
let s = s.trim();
let (num_str, unit) = s.split_once(' ')?;
let n: i64 = num_str.trim().parse().ok()?;
Some((n, unit.trim()))
}
fn apply_offset(base: DateTime<Utc>, amount: i64, unit: &str) -> Option<DateTime<Utc>> {
let unit = unit.trim_end_matches('s'); let dt = match unit {
"second" => base + Duration::seconds(amount),
"minute" => base + Duration::minutes(amount),
"hour" => base + Duration::hours(amount),
"day" => base + Duration::days(amount),
"week" => base + Duration::weeks(amount),
"month" => {
base + Duration::days(amount * 30)
}
"year" => base + Duration::days(amount * 365),
_ => return None,
};
Some(dt)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Local, Timelike};
fn today() -> NaiveDate {
Local::now().date_naive()
}
#[test]
fn test_parse_now() {
let before = Utc::now();
let result = parse_time("now").unwrap();
let after = Utc::now();
match result {
ParsedTime::DateTime(dt) => {
assert!(dt >= before, "dt should be >= before");
assert!(dt <= after, "dt should be <= after");
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_today() {
let result = parse_time("today").unwrap();
match result {
ParsedTime::DateOnly(d) => assert_eq!(d, today()),
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_tomorrow() {
let result = parse_time("tomorrow").unwrap();
match result {
ParsedTime::DateOnly(d) => assert_eq!(d, today() + Duration::days(1)),
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_yesterday() {
let result = parse_time("yesterday").unwrap();
match result {
ParsedTime::DateOnly(d) => assert_eq!(d, today() - Duration::days(1)),
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_next_week() {
let result = parse_time("next week").unwrap();
match result {
ParsedTime::DateOnly(d) => assert_eq!(d, today() + Duration::weeks(1)),
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_last_week() {
let result = parse_time("last week").unwrap();
match result {
ParsedTime::DateOnly(d) => assert_eq!(d, today() - Duration::weeks(1)),
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_days_ago() {
let before = Utc::now();
let result = parse_time("3 days ago").unwrap();
match result {
ParsedTime::DateTime(dt) => {
let expected = before - Duration::days(3);
assert!(
(dt - expected).num_seconds().abs() <= 2,
"dt={dt} expected~{expected}"
);
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_hours_ago() {
let before = Utc::now();
let result = parse_time("2 hours ago").unwrap();
match result {
ParsedTime::DateTime(dt) => {
let expected = before - Duration::hours(2);
assert!(
(dt - expected).num_seconds().abs() <= 2,
"dt={dt} expected~{expected}"
);
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_in_days() {
let before = Utc::now();
let result = parse_time("in 5 days").unwrap();
match result {
ParsedTime::DateTime(dt) => {
let expected = before + Duration::days(5);
assert!(
(dt - expected).num_seconds().abs() <= 2,
"dt={dt} expected~{expected}"
);
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_in_hours() {
let before = Utc::now();
let result = parse_time("in 2 hours").unwrap();
match result {
ParsedTime::DateTime(dt) => {
let expected = before + Duration::hours(2);
assert!(
(dt - expected).num_seconds().abs() <= 2,
"dt={dt} expected~{expected}"
);
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_iso8601_date() {
let result = parse_time("2026-01-15").unwrap();
match result {
ParsedTime::DateOnly(d) => {
assert_eq!(d.year(), 2026);
assert_eq!(d.month(), 1);
assert_eq!(d.day(), 15);
}
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_iso8601_datetime() {
let result = parse_time("2026-01-15T10:30:00Z").unwrap();
match result {
ParsedTime::DateTime(dt) => {
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
}
_ => panic!("expected DateTime variant"),
}
}
#[test]
fn test_parse_us_format() {
let result = parse_time("01/15/2026").unwrap();
match result {
ParsedTime::DateOnly(d) => {
assert_eq!(d.year(), 2026);
assert_eq!(d.month(), 1);
assert_eq!(d.day(), 15);
}
_ => panic!("expected DateOnly variant"),
}
}
#[test]
fn test_parse_empty_fails() {
assert!(parse_time("").is_err());
assert!(parse_time(" ").is_err());
}
#[test]
fn test_parse_garbage_fails() {
assert!(parse_time("not-a-date").is_err());
assert!(parse_time("foobar").is_err());
}
#[test]
fn test_parse_case_insensitive() {
assert!(matches!(parse_time("TODAY").unwrap(), ParsedTime::DateOnly(_)));
assert!(matches!(parse_time("Today").unwrap(), ParsedTime::DateOnly(_)));
assert!(matches!(parse_time("NOW").unwrap(), ParsedTime::DateTime(_)));
assert!(matches!(
parse_time("TOMORROW").unwrap(),
ParsedTime::DateOnly(_)
));
assert!(matches!(
parse_time("YESTERDAY").unwrap(),
ParsedTime::DateOnly(_)
));
assert!(matches!(
parse_time("NEXT WEEK").unwrap(),
ParsedTime::DateOnly(_)
));
assert!(matches!(
parse_time("LAST WEEK").unwrap(),
ParsedTime::DateOnly(_)
));
assert!(matches!(
parse_time("3 DAYS AGO").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("IN 5 DAYS").unwrap(),
ParsedTime::DateTime(_)
));
}
#[test]
fn test_parse_singular_forms() {
assert!(matches!(
parse_time("1 day ago").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("1 hour ago").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("1 minute ago").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("1 week ago").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("1 month ago").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("in 1 day").unwrap(),
ParsedTime::DateTime(_)
));
assert!(matches!(
parse_time("in 1 hour").unwrap(),
ParsedTime::DateTime(_)
));
}
}