Skip to content

Commit 938aa96

Browse files
authored
Merge pull request #100 from jfinkels/add-months-overflow-surplus-days
Overflow surplus days when adding months to a date
2 parents 51e2947 + 90d22a2 commit 938aa96

File tree

1 file changed

+116
-2
lines changed

1 file changed

+116
-2
lines changed

src/parse_relative_time.rs

+116-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
// For the full copyright and license information, please view the LICENSE
22
// file that was distributed with this source code.
33
use crate::ParseDateTimeError;
4-
use chrono::{DateTime, Days, Duration, Months, TimeZone};
4+
use chrono::{
5+
DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone,
6+
};
57
use regex::Regex;
68

9+
/// Number of days in each month.
10+
///
11+
/// Months are 0-indexed, so January is at index 0. The number of days
12+
/// in February is 28.
13+
const DAYS_PER_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
14+
715
/// Parses a relative time string and adds the duration that it represents to the
816
/// given date.
917
///
@@ -145,7 +153,89 @@ fn add_months<T: TimeZone>(
145153
if is_ago {
146154
datetime.checked_sub_months(Months::new(months))
147155
} else {
148-
datetime.checked_add_months(Months::new(months))
156+
checked_add_months(datetime, months)
157+
}
158+
}
159+
160+
/// Whether the given year is a leap year.
161+
fn is_leap_year(year: i32) -> bool {
162+
NaiveDate::from_ymd_opt(year, 1, 1).is_some_and(|d| d.leap_year())
163+
}
164+
165+
/// Get the number of days in the month in a particular year.
166+
///
167+
/// The year is required because February has 29 days in leap years.
168+
fn days_in_month(year: i32, month: u32) -> u32 {
169+
if is_leap_year(year) && month == 2 {
170+
29
171+
} else {
172+
DAYS_PER_MONTH[month as usize - 1]
173+
}
174+
}
175+
176+
/// Get the datetime at the given number of months ahead.
177+
///
178+
/// If the date is out of range or would be ambiguous (in the case of a
179+
/// fold in the local time), return `None`.
180+
///
181+
/// If the day would be out of range in the new month (for example, if
182+
/// `datetime` has day 31 but the resulting month only has 30 days),
183+
/// then the surplus days are rolled over into the following month.
184+
///
185+
/// # Examples
186+
///
187+
/// Surplus days are rolled over
188+
///
189+
/// ```rust,ignore
190+
/// use chrono::{NaiveDate, TimeZone, Utc};
191+
/// let datetime = Utc.from_utc_datetime(
192+
/// &NaiveDate::from_ymd_opt(1996, 3, 31).unwrap().into()
193+
/// );
194+
/// let new_datetime = checked_add_months(datetime, 1).unwrap();
195+
/// assert_eq!(
196+
/// new_datetime,
197+
/// Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 5, 1).unwrap().into()),
198+
/// );
199+
/// ```
200+
fn checked_add_months<T>(datetime: DateTime<T>, months: u32) -> Option<DateTime<T>>
201+
where
202+
T: TimeZone,
203+
{
204+
// The starting date.
205+
let ref_year = datetime.year();
206+
let ref_month = datetime.month();
207+
let ref_date_in_months = 12 * ref_year + (ref_month as i32) - 1;
208+
209+
// The year, month, and day of the target date.
210+
let target_date_in_months = ref_date_in_months.checked_add(months as i32)?;
211+
let year = target_date_in_months.div_euclid(12);
212+
let month = target_date_in_months.rem_euclid(12) + 1;
213+
let day = datetime.day();
214+
215+
// Account for overflow when getting the correct day in the next
216+
// month. For example,
217+
//
218+
// $ date -I --date '1996-01-31 +1 month' # a leap year
219+
// 1996-03-02
220+
// $ date -I --date '1997-01-31 +1 month' # a non-leap year
221+
// 1997-03-03
222+
//
223+
let (month, day) = if day > days_in_month(year, month as u32) {
224+
(month + 1, day - days_in_month(year, month as u32))
225+
} else {
226+
(month, datetime.day())
227+
};
228+
229+
// Create the new timezone-naive datetime.
230+
let new_date = NaiveDate::from_ymd_opt(year, month as u32, day)?;
231+
let time = datetime.time();
232+
let new_naive_datetime = NaiveDateTime::new(new_date, time);
233+
234+
// Make it timezone-aware.
235+
let offset = T::from_offset(datetime.offset());
236+
match offset.from_local_datetime(&new_naive_datetime) {
237+
LocalResult::Single(d) => Some(d),
238+
LocalResult::Ambiguous(_, _) | LocalResult::None => None,
149239
}
150240
}
151241

@@ -213,6 +303,20 @@ mod tests {
213303
);
214304
}
215305

306+
#[test]
307+
fn test_leap_day() {
308+
// $ date -I --date '1996-02-29 +1 year'
309+
// 1997-03-01
310+
// $ date -I --date '1996-02-29 +12 months'
311+
// 1997-03-01
312+
let datetime = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 2, 29).unwrap().into());
313+
let expected = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1997, 3, 1).unwrap().into());
314+
let parse = |s| parse_relative_time_at_date(datetime, s).unwrap();
315+
assert_eq!(parse("+1 year"), expected);
316+
assert_eq!(parse("+12 months"), expected);
317+
assert_eq!(parse("+366 days"), expected);
318+
}
319+
216320
#[test]
217321
fn test_months() {
218322
let now = Utc::now();
@@ -248,6 +352,16 @@ mod tests {
248352
);
249353
}
250354

355+
#[test]
356+
fn test_overflow_days_in_month() {
357+
// $ date -I --date '1996-03-31 1 month'
358+
// 1996-05-01
359+
let datetime = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 3, 31).unwrap().into());
360+
let expected = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 5, 1).unwrap().into());
361+
let parse = |s| parse_relative_time_at_date(datetime, s).unwrap();
362+
assert_eq!(parse("1 month"), expected);
363+
}
364+
251365
#[test]
252366
fn test_fortnights() {
253367
assert_eq!(

0 commit comments

Comments
 (0)