diff --git a/src/items/mod.rs b/src/items/mod.rs index d06257d..39fd695 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -345,11 +345,31 @@ fn last_day_of_month(year: i32, month: u32) -> u32 { .day() } -fn at_date_inner(date: Vec, mut d: DateTime) -> Option> { - d = d.with_hour(0).unwrap(); - d = d.with_minute(0).unwrap(); - d = d.with_second(0).unwrap(); - d = d.with_nanosecond(0).unwrap(); +fn at_date_inner(date: Vec, at: DateTime) -> Option> { + let mut d = at + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + + // This flag is used by relative items to determine which date/time to use. + // If any date/time item is set, it will use that; otherwise, it will use + // the `at` value. + let date_time_set = date.iter().any(|item| { + matches!( + item, + Item::Timestamp(_) + | Item::Date(_) + | Item::DateTime(_) + | Item::Year(_) + | Item::Time(_) + | Item::Weekday(_) + ) + }); for item in date { match item { @@ -416,54 +436,84 @@ fn at_date_inner(date: Vec, mut d: DateTime) -> Option { - let mut beginning_of_day = d - .with_hour(0) - .unwrap() - .with_minute(0) - .unwrap() - .with_second(0) - .unwrap() - .with_nanosecond(0) - .unwrap(); + Item::Weekday(weekday::Weekday { offset: x, day }) => { + let mut x = x; let day = day.into(); - while beginning_of_day.weekday() != day { - beginning_of_day += chrono::Duration::days(1); + // If the current day is not the target day, we need to adjust + // the x value to ensure we find the correct day. + // + // Consider this: + // Assuming today is Monday, next Friday is actually THIS Friday; + // but next Monday is indeed NEXT Monday. + if d.weekday() != day && x > 0 { + x -= 1; } - d = beginning_of_day - } - Item::Relative(relative::Relative::Years(x)) => { - d = d.with_year(d.year() + x)?; - } - Item::Relative(relative::Relative::Months(x)) => { - // *NOTE* This is done in this way to conform to - // GNU behavior. - let days = last_day_of_month(d.year(), d.month()); - if x >= 0 { - d += d - .date_naive() - .checked_add_days(chrono::Days::new((days * x as u32) as u64))? - .signed_duration_since(d.date_naive()); + // Calculate the delta to the target day. + // + // Assuming today is Thursday, here are some examples: + // + // Example 1: last Thursday (x = -1, day = Thursday) + // delta = (3 - 3) % 7 + (-1) * 7 = -7 + // + // Example 2: last Monday (x = -1, day = Monday) + // delta = (0 - 3) % 7 + (-1) * 7 = -3 + // + // Example 3: next Monday (x = 1, day = Monday) + // delta = (0 - 3) % 7 + (0) * 7 = 4 + // (Note that we have adjusted the x value above) + // + // Example 4: next Thursday (x = 1, day = Thursday) + // delta = (3 - 3) % 7 + (1) * 7 = 7 + let delta = (day.num_days_from_monday() as i32 + - d.weekday().num_days_from_monday() as i32) + .rem_euclid(7) + + x * 7; + + d = if delta < 0 { + d.checked_sub_days(chrono::Days::new((-delta) as u64))? } else { - d += d - .date_naive() - .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))? - .signed_duration_since(d.date_naive()); + d.checked_add_days(chrono::Days::new(delta as u64))? } } - Item::Relative(relative::Relative::Days(x)) => d += chrono::Duration::days(x.into()), - Item::Relative(relative::Relative::Hours(x)) => d += chrono::Duration::hours(x.into()), - Item::Relative(relative::Relative::Minutes(x)) => { - d += chrono::Duration::minutes(x.into()); - } - // Seconds are special because they can be given as a float - Item::Relative(relative::Relative::Seconds(x)) => { - d += chrono::Duration::seconds(x as i64); + Item::Relative(rel) => { + // If date and/or time is set, use the set value; otherwise, use + // the reference value. + if !date_time_set { + d = at; + } + + match rel { + relative::Relative::Years(x) => { + d = d.with_year(d.year() + x)?; + } + relative::Relative::Months(x) => { + // *NOTE* This is done in this way to conform to + // GNU behavior. + let days = last_day_of_month(d.year(), d.month()); + if x >= 0 { + d += d + .date_naive() + .checked_add_days(chrono::Days::new((days * x as u32) as u64))? + .signed_duration_since(d.date_naive()); + } else { + d += d + .date_naive() + .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))? + .signed_duration_since(d.date_naive()); + } + } + relative::Relative::Days(x) => d += chrono::Duration::days(x.into()), + relative::Relative::Hours(x) => d += chrono::Duration::hours(x.into()), + relative::Relative::Minutes(x) => { + d += chrono::Duration::minutes(x.into()); + } + // Seconds are special because they can be given as a float + relative::Relative::Seconds(x) => { + d += chrono::Duration::seconds(x as i64); + } + } } Item::TimeZone(offset) => { d = with_timezone_restore(offset, d)?; @@ -476,9 +526,9 @@ fn at_date_inner(date: Vec, mut d: DateTime) -> Option, - d: DateTime, + at: DateTime, ) -> Result, ParseDateTimeError> { - at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput) + at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput) } pub(crate) fn at_local(date: Vec) -> Result, ParseDateTimeError> { @@ -488,10 +538,12 @@ pub(crate) fn at_local(date: Vec) -> Result, ParseDa #[cfg(test)] mod tests { use super::{at_date, date::Date, parse, time::Time, Item}; - use chrono::{DateTime, FixedOffset}; + use chrono::{ + DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc, + }; fn at_utc(date: Vec) -> DateTime { - at_date(date, chrono::Utc::now().fixed_offset()).unwrap() + at_date(date, Utc::now().fixed_offset()).unwrap() } fn test_eq_fmt(fmt: &str, input: &str) -> String { @@ -610,4 +662,80 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("unexpected input")); } + + #[test] + fn relative_weekday() { + // Jan 1 2025 is a Wed + let now = Utc + .from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )) + .fixed_offset(); + + assert_eq!( + at_date(parse(&mut "last wed").unwrap(), now).unwrap(), + now - chrono::Duration::days(7) + ); + assert_eq!(at_date(parse(&mut "this wed").unwrap(), now).unwrap(), now); + assert_eq!( + at_date(parse(&mut "next wed").unwrap(), now).unwrap(), + now + chrono::Duration::days(7) + ); + assert_eq!( + at_date(parse(&mut "last thu").unwrap(), now).unwrap(), + now - chrono::Duration::days(6) + ); + assert_eq!( + at_date(parse(&mut "this thu").unwrap(), now).unwrap(), + now + chrono::Duration::days(1) + ); + assert_eq!( + at_date(parse(&mut "next thu").unwrap(), now).unwrap(), + now + chrono::Duration::days(1) + ); + assert_eq!( + at_date(parse(&mut "1 wed").unwrap(), now).unwrap(), + now + chrono::Duration::days(7) + ); + assert_eq!( + at_date(parse(&mut "1 thu").unwrap(), now).unwrap(), + now + chrono::Duration::days(1) + ); + assert_eq!( + at_date(parse(&mut "2 wed").unwrap(), now).unwrap(), + now + chrono::Duration::days(14) + ); + assert_eq!( + at_date(parse(&mut "2 thu").unwrap(), now).unwrap(), + now + chrono::Duration::days(8) + ); + } + + #[test] + fn relative_date_time() { + let now = Utc::now().fixed_offset(); + + let result = at_date(parse(&mut "2 days ago").unwrap(), now).unwrap(); + assert_eq!(result, now - chrono::Duration::days(2)); + assert_eq!(result.hour(), now.hour()); + assert_eq!(result.minute(), now.minute()); + assert_eq!(result.second(), now.second()); + + let result = at_date(parse(&mut "2025-01-01 2 days ago").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 0); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + + let result = at_date(parse(&mut "3 weeks").unwrap(), now).unwrap(); + assert_eq!(result, now + chrono::Duration::days(21)); + assert_eq!(result.hour(), now.hour()); + assert_eq!(result.minute(), now.minute()); + assert_eq!(result.second(), now.second()); + + let result = at_date(parse(&mut "2025-01-01 3 weeks").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 0); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + } }