Skip to content

Commit 9b67307

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

File tree

1 file changed

+178
-50
lines changed

1 file changed

+178
-50
lines changed

src/items/mod.rs

Lines changed: 178 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,31 @@ 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+
let date_time_set = date.iter().any(|item| {
363+
matches!(
364+
item,
365+
Item::Timestamp(_)
366+
| Item::Date(_)
367+
| Item::DateTime(_)
368+
| Item::Year(_)
369+
| Item::Time(_)
370+
| Item::Weekday(_)
371+
)
372+
});
353373

354374
for item in date {
355375
match item {
@@ -416,54 +436,84 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
416436
offset,
417437
)?;
418438
}
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();
439+
Item::Weekday(weekday::Weekday { offset: x, day }) => {
440+
let mut x = x;
432441
let day = day.into();
433442

434-
while beginning_of_day.weekday() != day {
435-
beginning_of_day += chrono::Duration::days(1);
443+
// If the current day is not the target day, we need to adjust
444+
// the x value to ensure we find the correct day.
445+
//
446+
// Consider this:
447+
// Assuming today is Monday, next Friday is actually THIS Friday;
448+
// but next Monday is indeed NEXT Monday.
449+
if d.weekday() != day && x > 0 {
450+
x -= 1;
436451
}
437452

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());
453+
// Calculate the delta to the target day.
454+
//
455+
// Assuming today is Thursday, here are some examples:
456+
//
457+
// Example 1: last Thursday (x = -1, day = Thursday)
458+
// delta = (3 - 3) % 7 + (-1) * 7 = -7
459+
//
460+
// Example 2: last Monday (x = -1, day = Monday)
461+
// delta = (0 - 3) % 7 + (-1) * 7 = -3
462+
//
463+
// Example 3: next Monday (x = 1, day = Monday)
464+
// delta = (0 - 3) % 7 + (0) * 7 = 4
465+
// (Note that we have adjusted the x value above)
466+
//
467+
// Example 4: next Thursday (x = 1, day = Thursday)
468+
// delta = (3 - 3) % 7 + (1) * 7 = 7
469+
let delta = (day.num_days_from_monday() as i32
470+
- d.weekday().num_days_from_monday() as i32)
471+
.rem_euclid(7)
472+
+ x * 7;
473+
474+
d = if delta < 0 {
475+
d.checked_sub_days(chrono::Days::new((-delta) as u64))?
452476
} 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());
477+
d.checked_add_days(chrono::Days::new(delta as u64))?
457478
}
458479
}
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);
480+
Item::Relative(rel) => {
481+
// If date and/or time is set, use the set value; otherwise, use
482+
// the reference value.
483+
if !date_time_set {
484+
d = at;
485+
}
486+
487+
match rel {
488+
relative::Relative::Years(x) => {
489+
d = d.with_year(d.year() + x)?;
490+
}
491+
relative::Relative::Months(x) => {
492+
// *NOTE* This is done in this way to conform to
493+
// GNU behavior.
494+
let days = last_day_of_month(d.year(), d.month());
495+
if x >= 0 {
496+
d += d
497+
.date_naive()
498+
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
499+
.signed_duration_since(d.date_naive());
500+
} else {
501+
d += d
502+
.date_naive()
503+
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
504+
.signed_duration_since(d.date_naive());
505+
}
506+
}
507+
relative::Relative::Days(x) => d += chrono::Duration::days(x.into()),
508+
relative::Relative::Hours(x) => d += chrono::Duration::hours(x.into()),
509+
relative::Relative::Minutes(x) => {
510+
d += chrono::Duration::minutes(x.into());
511+
}
512+
// Seconds are special because they can be given as a float
513+
relative::Relative::Seconds(x) => {
514+
d += chrono::Duration::seconds(x as i64);
515+
}
516+
}
467517
}
468518
Item::TimeZone(offset) => {
469519
d = with_timezone_restore(offset, d)?;
@@ -476,9 +526,9 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
476526

477527
pub(crate) fn at_date(
478528
date: Vec<Item>,
479-
d: DateTime<FixedOffset>,
529+
at: DateTime<FixedOffset>,
480530
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
481-
at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput)
531+
at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput)
482532
}
483533

484534
pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
@@ -488,10 +538,12 @@ pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDa
488538
#[cfg(test)]
489539
mod tests {
490540
use super::{at_date, date::Date, parse, time::Time, Item};
491-
use chrono::{DateTime, FixedOffset};
541+
use chrono::{
542+
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
543+
};
492544

493545
fn at_utc(date: Vec<Item>) -> DateTime<FixedOffset> {
494-
at_date(date, chrono::Utc::now().fixed_offset()).unwrap()
546+
at_date(date, Utc::now().fixed_offset()).unwrap()
495547
}
496548

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

0 commit comments

Comments
 (0)