Skip to content

Commit e5af24e

Browse files
committed
fix: relative date time handling
1 parent c05b821 commit e5af24e

File tree

1 file changed

+184
-50
lines changed

1 file changed

+184
-50
lines changed

src/items/mod.rs

Lines changed: 184 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,37 @@ fn last_day_of_month(year: i32, month: u32) -> u32 {
345345
.day()
346346
}
347347

348-
fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
349-
d = d.with_hour(0).unwrap();
350-
d = d.with_minute(0).unwrap();
351-
d = d.with_second(0).unwrap();
352-
d = d.with_nanosecond(0).unwrap();
348+
fn at_date_inner(date: Vec<Item>, at: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
349+
let mut d = at
350+
.with_hour(0)
351+
.unwrap()
352+
.with_minute(0)
353+
.unwrap()
354+
.with_second(0)
355+
.unwrap()
356+
.with_nanosecond(0)
357+
.unwrap();
358+
359+
// This flag is used by relative items to determine which date/time to use.
360+
// If any date/time item is set, it will use that; otherwise, it will use
361+
// the `at` value.
362+
//
363+
// TODO: find cleaner way to do this
364+
let mut date_time_set = false;
365+
for item in &date {
366+
match item {
367+
Item::Timestamp(_)
368+
| Item::Date(_)
369+
| Item::DateTime(_)
370+
| Item::Year(_)
371+
| Item::Time(_)
372+
| Item::Weekday(_) => {
373+
date_time_set = true;
374+
break;
375+
}
376+
_ => {}
377+
}
378+
}
353379

354380
for item in date {
355381
match item {
@@ -416,54 +442,84 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
416442
offset,
417443
)?;
418444
}
419-
Item::Weekday(weekday::Weekday {
420-
offset: _, // TODO: use the offset
421-
day,
422-
}) => {
423-
let mut beginning_of_day = d
424-
.with_hour(0)
425-
.unwrap()
426-
.with_minute(0)
427-
.unwrap()
428-
.with_second(0)
429-
.unwrap()
430-
.with_nanosecond(0)
431-
.unwrap();
445+
Item::Weekday(weekday::Weekday { offset: x, day }) => {
446+
let mut x = x;
432447
let day = day.into();
433448

434-
while beginning_of_day.weekday() != day {
435-
beginning_of_day += chrono::Duration::days(1);
449+
// If the current day is not the target day, we need to adjust
450+
// the x value to ensure we find the correct day.
451+
//
452+
// Consider this:
453+
// Assuming today is Monday, next Friday is actually THIS Friday;
454+
// but next Monday is indeed NEXT Monday.
455+
if d.weekday() != day && x > 0 {
456+
x -= 1;
436457
}
437458

438-
d = beginning_of_day
439-
}
440-
Item::Relative(relative::Relative::Years(x)) => {
441-
d = d.with_year(d.year() + x)?;
442-
}
443-
Item::Relative(relative::Relative::Months(x)) => {
444-
// *NOTE* This is done in this way to conform to
445-
// GNU behavior.
446-
let days = last_day_of_month(d.year(), d.month());
447-
if x >= 0 {
448-
d += d
449-
.date_naive()
450-
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
451-
.signed_duration_since(d.date_naive());
459+
// Calculate the delta to the target day.
460+
//
461+
// Assuming today is Thursday, here are some examples:
462+
//
463+
// Example 1: last Thursday (x = -1, day = Thursday)
464+
// delta = (3 - 3) % 7 + (-1) * 7 = -7
465+
//
466+
// Example 2: last Monday (x = -1, day = Monday)
467+
// delta = (0 - 3) % 7 + (-1) * 7 = -3
468+
//
469+
// Example 3: next Monday (x = 1, day = Monday)
470+
// delta = (0 - 3) % 7 + (0) * 7 = 4
471+
// (Note that we have adjusted the x value above)
472+
//
473+
// Example 4: next Thursday (x = 1, day = Thursday)
474+
// delta = (3 - 3) % 7 + (1) * 7 = 7
475+
let delta = (day.num_days_from_monday() as i32
476+
- d.weekday().num_days_from_monday() as i32)
477+
.rem_euclid(7)
478+
+ x * 7;
479+
480+
d = if delta < 0 {
481+
d.checked_sub_days(chrono::Days::new((-delta) as u64))?
452482
} else {
453-
d += d
454-
.date_naive()
455-
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
456-
.signed_duration_since(d.date_naive());
483+
d.checked_add_days(chrono::Days::new(delta as u64))?
457484
}
458485
}
459-
Item::Relative(relative::Relative::Days(x)) => d += chrono::Duration::days(x.into()),
460-
Item::Relative(relative::Relative::Hours(x)) => d += chrono::Duration::hours(x.into()),
461-
Item::Relative(relative::Relative::Minutes(x)) => {
462-
d += chrono::Duration::minutes(x.into());
463-
}
464-
// Seconds are special because they can be given as a float
465-
Item::Relative(relative::Relative::Seconds(x)) => {
466-
d += chrono::Duration::seconds(x as i64);
486+
Item::Relative(rel) => {
487+
// If date and/or time is set, use the set value; otherwise, use
488+
// the reference value.
489+
if !date_time_set {
490+
d = at;
491+
}
492+
493+
match rel {
494+
relative::Relative::Years(x) => {
495+
d = d.with_year(d.year() + x)?;
496+
}
497+
relative::Relative::Months(x) => {
498+
// *NOTE* This is done in this way to conform to
499+
// GNU behavior.
500+
let days = last_day_of_month(d.year(), d.month());
501+
if x >= 0 {
502+
d += d
503+
.date_naive()
504+
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
505+
.signed_duration_since(d.date_naive());
506+
} else {
507+
d += d
508+
.date_naive()
509+
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
510+
.signed_duration_since(d.date_naive());
511+
}
512+
}
513+
relative::Relative::Days(x) => d += chrono::Duration::days(x.into()),
514+
relative::Relative::Hours(x) => d += chrono::Duration::hours(x.into()),
515+
relative::Relative::Minutes(x) => {
516+
d += chrono::Duration::minutes(x.into());
517+
}
518+
// Seconds are special because they can be given as a float
519+
relative::Relative::Seconds(x) => {
520+
d += chrono::Duration::seconds(x as i64);
521+
}
522+
}
467523
}
468524
Item::TimeZone(offset) => {
469525
d = with_timezone_restore(offset, d)?;
@@ -476,9 +532,9 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
476532

477533
pub(crate) fn at_date(
478534
date: Vec<Item>,
479-
d: DateTime<FixedOffset>,
535+
at: DateTime<FixedOffset>,
480536
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
481-
at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput)
537+
at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput)
482538
}
483539

484540
pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
@@ -488,10 +544,12 @@ pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDa
488544
#[cfg(test)]
489545
mod tests {
490546
use super::{at_date, date::Date, parse, time::Time, Item};
491-
use chrono::{DateTime, FixedOffset};
547+
use chrono::{
548+
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
549+
};
492550

493551
fn at_utc(date: Vec<Item>) -> DateTime<FixedOffset> {
494-
at_date(date, chrono::Utc::now().fixed_offset()).unwrap()
552+
at_date(date, Utc::now().fixed_offset()).unwrap()
495553
}
496554

497555
fn test_eq_fmt(fmt: &str, input: &str) -> String {
@@ -610,4 +668,80 @@ mod tests {
610668
assert!(result.is_err());
611669
assert!(result.unwrap_err().to_string().contains("unexpected input"));
612670
}
671+
672+
#[test]
673+
fn relative_weekday() {
674+
// Jan 1 2025 is a Wed
675+
let now = Utc
676+
.from_utc_datetime(&NaiveDateTime::new(
677+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
678+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
679+
))
680+
.fixed_offset();
681+
682+
assert_eq!(
683+
at_date(parse(&mut "last wed").unwrap(), now).unwrap(),
684+
now - chrono::Duration::days(7)
685+
);
686+
assert_eq!(at_date(parse(&mut "this wed").unwrap(), now).unwrap(), now);
687+
assert_eq!(
688+
at_date(parse(&mut "next wed").unwrap(), now).unwrap(),
689+
now + chrono::Duration::days(7)
690+
);
691+
assert_eq!(
692+
at_date(parse(&mut "last thu").unwrap(), now).unwrap(),
693+
now - chrono::Duration::days(6)
694+
);
695+
assert_eq!(
696+
at_date(parse(&mut "this thu").unwrap(), now).unwrap(),
697+
now + chrono::Duration::days(1)
698+
);
699+
assert_eq!(
700+
at_date(parse(&mut "next thu").unwrap(), now).unwrap(),
701+
now + chrono::Duration::days(1)
702+
);
703+
assert_eq!(
704+
at_date(parse(&mut "1 wed").unwrap(), now).unwrap(),
705+
now + chrono::Duration::days(7)
706+
);
707+
assert_eq!(
708+
at_date(parse(&mut "1 thu").unwrap(), now).unwrap(),
709+
now + chrono::Duration::days(1)
710+
);
711+
assert_eq!(
712+
at_date(parse(&mut "2 wed").unwrap(), now).unwrap(),
713+
now + chrono::Duration::days(14)
714+
);
715+
assert_eq!(
716+
at_date(parse(&mut "2 thu").unwrap(), now).unwrap(),
717+
now + chrono::Duration::days(8)
718+
);
719+
}
720+
721+
#[test]
722+
fn relative_date_time() {
723+
let now = Utc::now().fixed_offset();
724+
725+
let result = at_date(parse(&mut "2 days ago").unwrap(), now).unwrap();
726+
assert_eq!(result, now - chrono::Duration::days(2));
727+
assert_eq!(result.hour(), now.hour());
728+
assert_eq!(result.minute(), now.minute());
729+
assert_eq!(result.second(), now.second());
730+
731+
let result = at_date(parse(&mut "2025-01-01 2 days ago").unwrap(), now).unwrap();
732+
assert_eq!(result.hour(), 0);
733+
assert_eq!(result.minute(), 0);
734+
assert_eq!(result.second(), 0);
735+
736+
let result = at_date(parse(&mut "3 weeks").unwrap(), now).unwrap();
737+
assert_eq!(result, now + chrono::Duration::days(21));
738+
assert_eq!(result.hour(), now.hour());
739+
assert_eq!(result.minute(), now.minute());
740+
assert_eq!(result.second(), now.second());
741+
742+
let result = at_date(parse(&mut "2025-01-01 3 weeks").unwrap(), now).unwrap();
743+
assert_eq!(result.hour(), 0);
744+
assert_eq!(result.minute(), 0);
745+
assert_eq!(result.second(), 0);
746+
}
613747
}

0 commit comments

Comments
 (0)