Skip to content

Commit 37c0acb

Browse files
committed
fix: relative date time handling
1 parent 84aaf2d commit 37c0acb

File tree

1 file changed

+229
-69
lines changed

1 file changed

+229
-69
lines changed

src/items/mod.rs

Lines changed: 229 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -345,29 +345,55 @@ 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 dt = 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 {
356382
Item::Timestamp(ts) => {
357-
d = chrono::Utc
383+
dt = chrono::Utc
358384
.timestamp_opt(ts.into(), 0)
359385
.unwrap()
360-
.with_timezone(&d.timezone())
386+
.with_timezone(&dt.timezone())
361387
}
362388
Item::Date(date::Date { day, month, year }) => {
363-
d = new_date(
364-
year.map(|x| x as i32).unwrap_or(d.year()),
389+
dt = new_date(
390+
year.map(|x| x as i32).unwrap_or(dt.year()),
365391
month,
366392
day,
367-
d.hour(),
368-
d.minute(),
369-
d.second(),
370-
*d.offset(),
393+
dt.hour(),
394+
dt.minute(),
395+
dt.second(),
396+
*dt.offset(),
371397
)?;
372398
}
373399
Item::DateTime(combined::DateTime {
@@ -383,10 +409,10 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
383409
}) => {
384410
let offset = offset
385411
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
386-
.unwrap_or(*d.offset());
412+
.unwrap_or(*dt.offset());
387413

388-
d = new_date(
389-
year.map(|x| x as i32).unwrap_or(d.year()),
414+
dt = new_date(
415+
year.map(|x| x as i32).unwrap_or(dt.year()),
390416
month,
391417
day,
392418
hour,
@@ -395,7 +421,7 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
395421
offset,
396422
)?;
397423
}
398-
Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d),
424+
Item::Year(year) => dt = dt.with_year(year as i32).unwrap_or(dt),
399425
Item::Time(time::Time {
400426
hour,
401427
minute,
@@ -404,81 +430,139 @@ fn at_date_inner(date: Vec<Item>, mut d: DateTime<FixedOffset>) -> Option<DateTi
404430
}) => {
405431
let offset = offset
406432
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
407-
.unwrap_or(*d.offset());
433+
.unwrap_or(*dt.offset());
408434

409-
d = new_date(
410-
d.year(),
411-
d.month(),
412-
d.day(),
435+
dt = new_date(
436+
dt.year(),
437+
dt.month(),
438+
dt.day(),
413439
hour,
414440
minute,
415441
second as u32,
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 dt.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+
- dt.weekday().num_days_from_monday() as i32)
477+
.rem_euclid(7)
478+
+ x * 7;
479+
480+
dt = if delta < 0 {
481+
dt.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+
dt.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+
dt = at;
491+
}
492+
493+
match rel {
494+
relative::Relative::Years(x) => {
495+
dt = dt.with_year(dt.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(dt.year(), dt.month());
501+
if x >= 0 {
502+
dt += dt
503+
.date_naive()
504+
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
505+
.signed_duration_since(dt.date_naive());
506+
} else {
507+
dt += dt
508+
.date_naive()
509+
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
510+
.signed_duration_since(dt.date_naive());
511+
}
512+
}
513+
relative::Relative::Days(x) => dt += chrono::Duration::days(x.into()),
514+
relative::Relative::Hours(x) => dt += chrono::Duration::hours(x.into()),
515+
relative::Relative::Minutes(x) => {
516+
dt += chrono::Duration::minutes(x.into());
517+
}
518+
// Seconds are special because they can be given as a float
519+
relative::Relative::Seconds(x) => {
520+
dt += chrono::Duration::seconds(x as i64);
521+
}
522+
}
467523
}
524+
// Item::Relative(relative::Relative::Years(x)) => {
525+
// dt = dt.with_year(dt.year() + x)?;
526+
// }
527+
// Item::Relative(relative::Relative::Months(x)) => {
528+
// // *NOTE* This is done in this way to conform to
529+
// // GNU behavior.
530+
// let days = last_day_of_month(dt.year(), dt.month());
531+
// if x >= 0 {
532+
// dt += dt
533+
// .date_naive()
534+
// .checked_add_days(chrono::Days::new((days * x as u32) as u64))?
535+
// .signed_duration_since(dt.date_naive());
536+
// } else {
537+
// dt += dt
538+
// .date_naive()
539+
// .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
540+
// .signed_duration_since(dt.date_naive());
541+
// }
542+
// }
543+
// Item::Relative(relative::Relative::Days(x)) => dt += chrono::Duration::days(x.into()),
544+
// Item::Relative(relative::Relative::Hours(x)) => dt += chrono::Duration::hours(x.into()),
545+
// Item::Relative(relative::Relative::Minutes(x)) => {
546+
// dt += chrono::Duration::minutes(x.into());
547+
// }
548+
// // Seconds are special because they can be given as a float
549+
// Item::Relative(relative::Relative::Seconds(x)) => {
550+
// dt += chrono::Duration::seconds(x as i64);
551+
// }
468552
Item::TimeZone(offset) => {
469-
d = with_timezone_restore(offset, d)?;
553+
dt = with_timezone_restore(offset, dt)?;
470554
}
471555
}
472556
}
473557

474-
Some(d)
558+
Some(dt)
475559
}
476560

477561
pub(crate) fn at_date(
478562
date: Vec<Item>,
479-
d: DateTime<FixedOffset>,
563+
at: DateTime<FixedOffset>,
480564
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
481-
at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput)
565+
at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput)
482566
}
483567

484568
pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
@@ -488,10 +572,12 @@ pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDa
488572
#[cfg(test)]
489573
mod tests {
490574
use super::{at_date, date::Date, parse, time::Time, Item};
491-
use chrono::{DateTime, FixedOffset};
575+
use chrono::{
576+
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
577+
};
492578

493579
fn at_utc(date: Vec<Item>) -> DateTime<FixedOffset> {
494-
at_date(date, chrono::Utc::now().fixed_offset()).unwrap()
580+
at_date(date, Utc::now().fixed_offset()).unwrap()
495581
}
496582

497583
fn test_eq_fmt(fmt: &str, input: &str) -> String {
@@ -561,6 +647,80 @@ mod tests {
561647
test_eq_fmt("%Y-%m-%d %H:%M:%S %:z", "Jul 17 06:14:49 2024 BRT"),
562648
);
563649
}
650+
#[test]
651+
fn relative_weekday() {
652+
// Jan 1 2025 is a Wed
653+
let now = Utc
654+
.from_utc_datetime(&NaiveDateTime::new(
655+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
656+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
657+
))
658+
.fixed_offset();
659+
660+
assert_eq!(
661+
at_date(parse(&mut "last wed").unwrap(), now).unwrap(),
662+
now - chrono::Duration::days(7)
663+
);
664+
assert_eq!(at_date(parse(&mut "this wed").unwrap(), now).unwrap(), now);
665+
assert_eq!(
666+
at_date(parse(&mut "next wed").unwrap(), now).unwrap(),
667+
now + chrono::Duration::days(7)
668+
);
669+
assert_eq!(
670+
at_date(parse(&mut "last thu").unwrap(), now).unwrap(),
671+
now - chrono::Duration::days(6)
672+
);
673+
assert_eq!(
674+
at_date(parse(&mut "this thu").unwrap(), now).unwrap(),
675+
now + chrono::Duration::days(1)
676+
);
677+
assert_eq!(
678+
at_date(parse(&mut "next thu").unwrap(), now).unwrap(),
679+
now + chrono::Duration::days(1)
680+
);
681+
assert_eq!(
682+
at_date(parse(&mut "1 wed").unwrap(), now).unwrap(),
683+
now + chrono::Duration::days(7)
684+
);
685+
assert_eq!(
686+
at_date(parse(&mut "1 thu").unwrap(), now).unwrap(),
687+
now + chrono::Duration::days(1)
688+
);
689+
assert_eq!(
690+
at_date(parse(&mut "2 wed").unwrap(), now).unwrap(),
691+
now + chrono::Duration::days(14)
692+
);
693+
assert_eq!(
694+
at_date(parse(&mut "2 thu").unwrap(), now).unwrap(),
695+
now + chrono::Duration::days(8)
696+
);
697+
}
698+
#[test]
699+
fn relative_date_time() {
700+
let now = Utc::now().fixed_offset();
701+
702+
let result = at_date(parse(&mut "2 days ago").unwrap(), now).unwrap();
703+
assert_eq!(result, now - chrono::Duration::days(2));
704+
assert_eq!(result.hour(), now.hour());
705+
assert_eq!(result.minute(), now.minute());
706+
assert_eq!(result.second(), now.second());
707+
708+
let result = at_date(parse(&mut "2025-01-01 2 days ago").unwrap(), now).unwrap();
709+
assert_eq!(result.hour(), 0);
710+
assert_eq!(result.minute(), 0);
711+
assert_eq!(result.second(), 0);
712+
713+
let result = at_date(parse(&mut "3 weeks").unwrap(), now).unwrap();
714+
assert_eq!(result, now + chrono::Duration::days(21));
715+
assert_eq!(result.hour(), now.hour());
716+
assert_eq!(result.minute(), now.minute());
717+
assert_eq!(result.second(), now.second());
718+
719+
let result = at_date(parse(&mut "2025-01-01 3 weeks").unwrap(), now).unwrap();
720+
assert_eq!(result.hour(), 0);
721+
assert_eq!(result.minute(), 0);
722+
assert_eq!(result.second(), 0);
723+
}
564724

565725
#[test]
566726
fn invalid() {

0 commit comments

Comments
 (0)