chronoutil

Crates.iochronoutil
lib.rschronoutil
version0.2.7
sourcesrc
created_at2021-01-05 16:16:24.466908
updated_at2024-04-24 19:58:24.503335
descriptionPowerful extensions to rust's Chrono crate
homepage
repositoryhttps://github.com/olliemath/chronoutil
max_upload_size
id332250
size80,243
Oliver Margetts (olliemath)

documentation

README

ChronoUtil: powerful extensions to Rust's Chrono crate.

ChronoUtil GitHub Actions ChronoUtil on crates.io ChronoUtil on docs.rs License: MIT

ChronoUtil provides the following utilities:

  • RelativeDuration: extending Chrono's Duration to add months and years
  • DateRules: useful iterators yielding regular (e.g. monthly) dates
  • Procedural helper functions for shifting datelike values by months and years

It is heavily inspired by Python's dateutil and provides a similar API, but with less of the niche functionality.

Usage

Put this in your Cargo.toml:

[dependencies]
chronoutil = "0.2.7"

Overview

RelativeDuration

ChronoUtils uses a RelativeDuration type to represent the magnitude of a time span which may not be absolute (i.e. which is not simply a fixed number of nanoseconds). A relative duration is made up of a number of months together with an absolute Duration component.

let one_day = RelativeDuration::days(1);
let one_month = RelativeDuration::months(1);
let delta = one_month + one_day;
let start = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
assert_eq!(start + delta, NaiveDate::from_ymd_opt(2020, 2, 2).unwrap());

The behaviour of RelativeDuration is consistent and well-defined in edge-cases (see the Design Decisions section for an explanation):

let one_day = RelativeDuration::days(1);
let one_month = RelativeDuration::months(1);
let delta = one_month + one_day;
let start = NaiveDate::from_ymd_opt(2020, 1, 30).unwrap();
assert_eq!(start + delta, NaiveDate::from_ymd_opt(2020, 3, 1).unwrap());

Relative durations also support parsing a subset of the ISO8601 spec for durations. For example:

let payload = String::from("P1Y2M-3DT1H2M3.4S");
let parsed = RelativeDuration::parse_from_iso8601(&payload).unwrap();
assert_eq!(
    parsed,
    RelativeDuration::years(1)
    + RelativeDuration::months(2)
    + RelativeDuration::days(-3)
    + RelativeDuration::hours(1)
    + RelativeDuration::minutes(2)
    + RelativeDuration::seconds(3)
    + RelativeDuration::nanoseconds(400_000_000)
)
assert_eq!(parsed.format_to_iso8601().unwrap(), payload)

Specifically, we require that all fields except the seconds be integers.

DateRule

ChronoUtil provides a DateRule iterator to reliably generate a collection of dates at regular intervals. For example, the following will yield one NaiveDate on the last day of each month in 2025:

let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
let rule = DateRule::monthly(start).with_count(12);
// 2025-1-31, 2025-2-28, 2025-3-31, 2025-4-30, ...

Shift functions

ChronoUtil also exposes useful shift functions which are used internally, namely:

  • shift_months to shift a datelike value by a given number of months
  • shift_years to shift a datelike value by a given number of years
  • with_year to shift a datelike value to a given day
  • with_month to shift a datelike value to a given month
  • with_year to shift a datelike value to a given year

Design decisions and gotchas

We favour simplicity over complexity: we use only the Gregorian calendar and make no changes e.g. for dates before the 1500s.

For days between the 1st and 28th, shifting by months has an obvious unambiguous meaning which we always stick to. One month after Jan 28th is always Feb 28th. Shifting Feb 28th by another month will give Mar 28th.

When shifting a day that has no equivalent in another month (e.g. asking for one month after Jan 30th), we first compute the target month, and then if the corresponding day does not exist in that month, we take the final day of the month as the result. So, on a leap year, one month after Jan 30th is Feb 29th.

The order of precidence for a RelativeDuration is as follows:

  1. Work out the target month, if shifting by months
  2. If the initial day does not exist in that month, take the final day of the month
  3. Execute any further Duration shifts

So a RelativeDuration of 1 month and 1 day applied to Jan 31st first shifts to the last day of Feb, and then adds a single day, giving the 1st of Mar. Applying to Jan 30th gives the same result.

Shifted dates have no memory of the date they were shifted from. Thus if we shift Jan 31st by one month and obtain Feb 28th, a further shift of one month will be Mar 28th, not Mar 31st.

This leads us to an interesting point about the RelativeDuration: addition is not associative:

let start = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap();
let delta = RelativeDuration::months(1);

let d1 = (start + delta) + delta;
let d2 = start + (delta + delta);

assert_eq!(d1, NaiveDate::from_ymd_opt(2020, 3, 29).unwrap());
assert_eq!(d2, NaiveDate::from_ymd_opt(2020, 3, 31).unwrap());

If you want a series of shifted dates, we advise using the DateRule, which takes account of some of these subtleties:

let start = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap();
let delta = RelativeDuration::months(1);
let mut rule = DateRule::new(start, delta);
assert_eq!(rule.next().unwrap(), NaiveDate::from_ymd_opt(2020, 1, 31).unwrap());
assert_eq!(rule.next().unwrap(), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap());
assert_eq!(rule.next().unwrap(), NaiveDate::from_ymd_opt(2020, 3, 31).unwrap());

Using custom Datelike types

If you have your own custom type which implements chrono's Datelike trait, then you can already use all of the shift functions (shift_months, shift_year).

Using relative duration for your type will involve some simple boilerplate. Assuming that your custom date type MyAwesomeUnicornDate already has an implementation of Add for chrono's Duration, this would look like:

impl Add<RelativeDuration> for MyAwesomeUnicornDate {
    type Output = MyAwesomeUnicornDate;

    #[inline]
    fn add(self, rhs: RelativeDuration) -> MyAwesomeUnicornDate {
        shift_months(self, rhs.months) + rhs.duration
    }
}
Commit count: 75

cargo fmt