chronoutil-0.2.7/.cargo_vcs_info.json0000644000000001360000000000100132300ustar { "git": { "sha1": "1e866a8c605773fca2984d440e043f29d633ce4c" }, "path_in_vcs": "" }chronoutil-0.2.7/.gitignore000064400000000000000000000005321046102023000140100ustar 00000000000000# Generated by Cargo # will have compiled files and executables /target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk # Misc scripts .scripts/ chronoutil-0.2.7/Cargo.toml0000644000000021650000000000100112320ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2018" name = "chronoutil" version = "0.2.7" authors = ["olliemath "] exclude = [".github/*"] description = "Powerful extensions to rust's Chrono crate" readme = "README.md" keywords = [ "date", "time", "calendar", ] categories = ["date-and-time"] license = "MIT" repository = "https://github.com/olliemath/chronoutil" [[bench]] name = "delta" harness = false [[bench]] name = "relative_duration" harness = false [dependencies.chrono] version = "^0.4.34" default-features = false [dev-dependencies.chrono-tz] version = "0.8.3" [dev-dependencies.criterion] version = "0.3" [dev-dependencies.proptest] version = "1.4.0" chronoutil-0.2.7/Cargo.toml.orig000064400000000000000000000011341046102023000147060ustar 00000000000000[package] authors = ["olliemath "] categories = ["date-and-time"] description = "Powerful extensions to rust's Chrono crate" edition = "2018" exclude = [".github/*"] keywords = ["date", "time", "calendar"] license = "MIT" name = "chronoutil" readme = "README.md" repository = "https://github.com/olliemath/chronoutil" version = "0.2.7" [dependencies] "chrono" = { version = "^0.4.34", default-features = false } [dev-dependencies] criterion = "0.3" chrono-tz = "0.8.3" proptest = "1.4.0" [[bench]] name = "delta" harness = false [[bench]] name = "relative_duration" harness = false chronoutil-0.2.7/LICENSE000064400000000000000000000020651046102023000130300ustar 00000000000000MIT License Copyright (c) 2020-2023 Oliver Margetts Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. chronoutil-0.2.7/README.md000064400000000000000000000161321046102023000133020ustar 00000000000000# [ChronoUtil][docsrs]: powerful extensions to Rust's [Chrono](https://github.com/chronotope/chrono) crate. [![ChronoUtil GitHub Actions][gh-image]][gh-checks] [![ChronoUtil on crates.io][cratesio-image]][cratesio] [![ChronoUtil on docs.rs][docsrs-image]][docsrs] [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [gh-image]: https://github.com/olliemath/chronoutil/workflows/test/badge.svg [gh-checks]: https://github.com/olliemath/chronoutil/actions?query=workflow%3Atest [cratesio-image]: https://img.shields.io/crates/v/chronoutil.svg [cratesio]: https://crates.io/crates/chronoutil [docsrs-image]: https://docs.rs/chronoutil/badge.svg [docsrs]: https://docs.rs/chronoutil ChronoUtil provides the following utilities: - `RelativeDuration`: extending Chrono's `Duration` to add months and years - `DateRule`s: 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](https://github.com/dateutil/dateutil) and provides a similar API, but with less of the niche functionality. ## Usage Put this in your `Cargo.toml`: ```toml [dependencies] chronoutil = "0.2.7" ``` ## Overview ### RelativeDuration ChronoUtils uses a [**`RelativeDuration`**](https://docs.rs/chronoutil/0.2.7/chronoutil/relative_duration/struct.RelativeDuration.html) 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. ```rust 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): ```rust 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: ```rust 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`**](https://docs.rs/chronoutil/0.2.7/chronoutil/rule/struct.DateRule.html) 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: ```rust 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`**](https://docs.rs/chronoutil/0.2.7/chronoutil/delta/fn.shift_months.html) to shift a datelike value by a given number of months - [**`shift_years`**](https://docs.rs/chronoutil/0.2.7/chronoutil/delta/fn.shift_years.html) to shift a datelike value by a given number of years - [**`with_year`**](https://docs.rs/chronoutil/0.2.7/chronoutil/delta/fn.shift_months.html) to shift a datelike value to a given day - [**`with_month`**](https://docs.rs/chronoutil/0.2.7/chronoutil/delta/fn.with_month.html) to shift a datelike value to a given month - [**`with_year`**](https://docs.rs/chronoutil/0.2.7/chronoutil/delta/fn.with_year.html) 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](https://en.wikipedia.org/wiki/Associative_property)_: ```rust 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: ```rust 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](https://docs.rs/chrono/0.4.19/chrono/trait.Datelike.html) 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: ```rust impl Add for MyAwesomeUnicornDate { type Output = MyAwesomeUnicornDate; #[inline] fn add(self, rhs: RelativeDuration) -> MyAwesomeUnicornDate { shift_months(self, rhs.months) + rhs.duration } } ``` chronoutil-0.2.7/benches/delta.rs000064400000000000000000000024041046102023000150660ustar 00000000000000use chrono::naive::NaiveDate; use criterion::{black_box, criterion_group, criterion_main, Criterion}; use chronoutil::delta::{is_leap_year, shift_months}; fn shift_months_benchmark(c: &mut Criterion) { let shifts = black_box(-18..19); let base = NaiveDate::from_ymd_opt(2020, 12, 31).unwrap(); c.bench_function("shift_months", |b| { b.iter::, _>(|| shifts.clone().map(|s| shift_months(base, s)).collect()) }); } fn in_month(y: i32, m: u32, d: u32) -> bool { if m == 4 || m == 6 || m == 9 || m == 11 { d <= 30 } else if m == 2 { d <= 28 || (d == 29 && is_leap_year(y)) } else { true } } fn shift_months_many_benchmark(c: &mut Criterion) { let shift = black_box(6); let mut bases = Vec::new(); for y in 0..101 { for m in 1..13 { for d in 1..31 { if in_month(y, m, d) { bases.push(NaiveDate::from_ymd_opt(y, m, d).unwrap()); } } } } c.bench_function("shift_months_many", |b| { b.iter::, _>(|| bases.iter().map(|b| shift_months(*b, shift)).collect()) }); } criterion_group!(benches, shift_months_benchmark, shift_months_many_benchmark); criterion_main!(benches); chronoutil-0.2.7/benches/relative_duration.rs000064400000000000000000000031031046102023000175120ustar 00000000000000use criterion::{black_box, criterion_group, criterion_main, Criterion}; use chronoutil::RelativeDuration; fn relative_duration_format_benchmark(c: &mut Criterion) { let durations = [ "P1M", "P1Y1M1W1DT1H1M1S", "P99999999Y11M30DT23H59M59.999999999S", ] .iter() .map(|s| RelativeDuration::parse_from_iso8601(s).unwrap()) .collect::>(); let mut g = c.benchmark_group("relative_duration_format"); g.bench_function("one_specifier", |b| { b.iter(|| black_box(durations[0]).format_to_iso8601()) }); g.bench_function("all_specifiers", |b| { b.iter(|| black_box(durations[1]).format_to_iso8601()) }); g.bench_function("long_specifiers", |b| { b.iter(|| black_box(durations[2]).format_to_iso8601()) }); } fn relative_duration_parse_benchmark(c: &mut Criterion) { let durations = [ "P1M", "P1Y1M1W1DT1H1M1S", "P99999999Y11M30DT23H59M59.999999999S", ]; let mut g = c.benchmark_group("relative_duration_parse"); g.bench_function("one_specifier", |b| { b.iter(|| RelativeDuration::parse_from_iso8601(black_box(durations[0]))) }); g.bench_function("all_specifiers", |b| { b.iter(|| RelativeDuration::parse_from_iso8601(black_box(durations[1]))) }); g.bench_function("long_specifiers", |b| { b.iter(|| RelativeDuration::parse_from_iso8601(black_box(durations[2]))) }); } criterion_group!( benches, relative_duration_format_benchmark, relative_duration_parse_benchmark ); criterion_main!(benches); chronoutil-0.2.7/src/delta.rs000064400000000000000000000454561046102023000142640ustar 00000000000000//! Contains utility functions for shifting Date objects. use chrono::Datelike; /// Returns true if the year is a leap-year, as naively defined in the Gregorian calendar. #[inline] pub fn is_leap_year(year: i32) -> bool { year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } // If the day lies within the month, this function has no effect. Otherwise, it shifts // day backwards to the final day of the month. // XXX: No attempt is made to handle days outside the 1-31 range. #[inline] fn normalise_day(year: i32, month: u32, day: u32) -> u32 { if day <= 28 { day } else if month == 2 { 28 + is_leap_year(year) as u32 } else if day == 31 && (month == 4 || month == 6 || month == 9 || month == 11) { 30 } else { day } } /// Shift a date by the given number of months. /// Ambiguous month-ends are shifted backwards as necessary. pub fn shift_months(date: D, months: i32) -> D { shift_months_opt(date, months).unwrap() } /// Same as [`shift_months`] except fallible on unresolvable dates/times. /// /// Returns `None` rather than panicking when shift results in an ambiguous or non-existing /// date/time (e.g. in a DST transition). pub fn shift_months_opt(date: D, months: i32) -> Option { let mut year = date.year() + (date.month() as i32 + months) / 12; let mut month = (date.month() as i32 + months) % 12; let mut day = date.day(); if month < 1 { year -= 1; month += 12; } day = normalise_day(year, month as u32, day); // This is slow but guaranteed to succeed (short of interger overflow) if day <= 28 { date.with_day(day)? .with_month(month as u32)? .with_year(year) } else { date.with_day(1)? .with_month(month as u32)? .with_year(year)? .with_day(day) } } /// Shift a date by the given number of years. /// Ambiguous month-ends are shifted backwards as necessary. pub fn shift_years(date: D, years: i32) -> D { shift_years_opt(date, years).unwrap() } /// Same as [`shift_years`] except fallible on unresolvable dates/times. /// /// Returns `None` rather than panicking when shift results in an ambiguous or non-existing /// date/time (e.g. in a DST transition). pub fn shift_years_opt(date: D, years: i32) -> Option { shift_months_opt(date, years * 12) } /// Shift the date to have the given day. Returns None if the day is not in the range 1-31. /// /// Ambiguous month-ends are shifted backwards as necessary. /// For example: /// ```rust /// # use chrono::NaiveDate; /// # use chronoutil::with_day; /// let start = NaiveDate::from_ymd_opt(2020, 2, 1).unwrap(); /// assert_eq!(with_day(start, 31), NaiveDate::from_ymd_opt(2020, 2, 29)); /// assert_eq!(with_day(start, 42), None); /// ``` pub fn with_day(date: D, day: u32) -> Option { if day == 0 || day > 31 { None } else { date.with_day(normalise_day(date.year(), date.month(), day)) } } /// Shift the date to have the given month. Returns None if the month is out of range. /// /// Ambiguous month-ends are shifted backwards as necessary. /// For example: /// ```rust /// # use chrono::NaiveDate; /// # use chronoutil::with_month; /// let start = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap(); /// assert_eq!(with_month(start, 2), NaiveDate::from_ymd_opt(2020, 2, 29)); /// assert_eq!(with_month(start, 13), None); /// ``` pub fn with_month(date: D, month: u32) -> Option { if month == 0 || month > 12 { None } else { let delta = month as i32 - date.month() as i32; Some(shift_months(date, delta)) } } /// Similar to [`with_month`] except _also_ fallible on unresolvable dates/times. /// /// In addition to returning `None` when the month arg is out of range, also returns `None` rather /// than panicking when shift results in an ambiguous or non-existing date/time (e.g. in a DST /// transition). pub fn with_month_opt(date: D, month: u32) -> Option { if month == 0 || month > 12 { None } else { let delta = month as i32 - date.month() as i32; shift_months_opt(date, delta) } } /// Shift the date to have the given year. /// /// Ambiguous month-ends are shifted backwards as necessary. /// For example: /// ```rust /// # use chrono::NaiveDate; /// # use chronoutil::with_year; /// let start = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); /// assert_eq!(with_year(start, 2021), NaiveDate::from_ymd_opt(2021, 2, 28).unwrap()); /// ``` pub fn with_year(date: D, year: i32) -> D { with_year_opt(date, year).unwrap() } /// Same as [`with_year`] except fallible on unresolvable dates/times. /// /// Returns `None` rather than panicking when shift results in an ambiguous or non-existing /// date/time (e.g. in a DST transition). pub fn with_year_opt(date: D, year: i32) -> Option { let delta = year - date.year(); shift_years_opt(date, delta) } #[cfg(test)] mod tests { use std::collections::HashSet; use chrono::{ naive::{NaiveDate, NaiveDateTime, NaiveTime}, LocalResult, TimeZone, }; use super::*; #[test] fn test_leap_year_cases() { let _leap_years: Vec = vec![ 1904, 1908, 1912, 1916, 1920, 1924, 1928, 1932, 1936, 1940, 1944, 1948, 1952, 1956, 1960, 1964, 1968, 1972, 1976, 1980, 1984, 1988, 1992, 1996, 2000, 2004, 2008, 2012, 2016, 2020, ]; let leap_years_1900_to_2020: HashSet = _leap_years.into_iter().collect(); for year in 1900..2021 { assert_eq!(is_leap_year(year), leap_years_1900_to_2020.contains(&year)) } } #[test] fn test_shift_months() { let base = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap(); assert_eq!( shift_months(base, 0), NaiveDate::from_ymd_opt(2020, 1, 31).unwrap() ); assert_eq!( shift_months(base, 1), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( shift_months(base, 2), NaiveDate::from_ymd_opt(2020, 3, 31).unwrap() ); assert_eq!( shift_months(base, 3), NaiveDate::from_ymd_opt(2020, 4, 30).unwrap() ); assert_eq!( shift_months(base, 4), NaiveDate::from_ymd_opt(2020, 5, 31).unwrap() ); assert_eq!( shift_months(base, 5), NaiveDate::from_ymd_opt(2020, 6, 30).unwrap() ); assert_eq!( shift_months(base, 6), NaiveDate::from_ymd_opt(2020, 7, 31).unwrap() ); assert_eq!( shift_months(base, 7), NaiveDate::from_ymd_opt(2020, 8, 31).unwrap() ); assert_eq!( shift_months(base, 8), NaiveDate::from_ymd_opt(2020, 9, 30).unwrap() ); assert_eq!( shift_months(base, 9), NaiveDate::from_ymd_opt(2020, 10, 31).unwrap() ); assert_eq!( shift_months(base, 10), NaiveDate::from_ymd_opt(2020, 11, 30).unwrap() ); assert_eq!( shift_months(base, 11), NaiveDate::from_ymd_opt(2020, 12, 31).unwrap() ); assert_eq!( shift_months(base, 12), NaiveDate::from_ymd_opt(2021, 1, 31).unwrap() ); assert_eq!( shift_months(base, 13), NaiveDate::from_ymd_opt(2021, 2, 28).unwrap() ); assert_eq!( shift_months(base, -1), NaiveDate::from_ymd_opt(2019, 12, 31).unwrap() ); assert_eq!( shift_months(base, -2), NaiveDate::from_ymd_opt(2019, 11, 30).unwrap() ); assert_eq!( shift_months(base, -3), NaiveDate::from_ymd_opt(2019, 10, 31).unwrap() ); assert_eq!( shift_months(base, -4), NaiveDate::from_ymd_opt(2019, 9, 30).unwrap() ); assert_eq!( shift_months(base, -5), NaiveDate::from_ymd_opt(2019, 8, 31).unwrap() ); assert_eq!( shift_months(base, -6), NaiveDate::from_ymd_opt(2019, 7, 31).unwrap() ); assert_eq!( shift_months(base, -7), NaiveDate::from_ymd_opt(2019, 6, 30).unwrap() ); assert_eq!( shift_months(base, -8), NaiveDate::from_ymd_opt(2019, 5, 31).unwrap() ); assert_eq!( shift_months(base, -9), NaiveDate::from_ymd_opt(2019, 4, 30).unwrap() ); assert_eq!( shift_months(base, -10), NaiveDate::from_ymd_opt(2019, 3, 31).unwrap() ); assert_eq!( shift_months(base, -11), NaiveDate::from_ymd_opt(2019, 2, 28).unwrap() ); assert_eq!( shift_months(base, -12), NaiveDate::from_ymd_opt(2019, 1, 31).unwrap() ); assert_eq!( shift_months(base, -13), NaiveDate::from_ymd_opt(2018, 12, 31).unwrap() ); assert_eq!( shift_months(base, 1265), NaiveDate::from_ymd_opt(2125, 6, 30).unwrap() ); } #[test] fn test_shift_months_with_overflow() { let base = NaiveDate::from_ymd_opt(2020, 12, 31).unwrap(); assert_eq!(shift_months(base, 0), base); assert_eq!( shift_months(base, 1), NaiveDate::from_ymd_opt(2021, 1, 31).unwrap() ); assert_eq!( shift_months(base, 2), NaiveDate::from_ymd_opt(2021, 2, 28).unwrap() ); assert_eq!( shift_months(base, 12), NaiveDate::from_ymd_opt(2021, 12, 31).unwrap() ); assert_eq!( shift_months(base, 18), NaiveDate::from_ymd_opt(2022, 6, 30).unwrap() ); assert_eq!( shift_months(base, -1), NaiveDate::from_ymd_opt(2020, 11, 30).unwrap() ); assert_eq!( shift_months(base, -2), NaiveDate::from_ymd_opt(2020, 10, 31).unwrap() ); assert_eq!( shift_months(base, -10), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( shift_months(base, -12), NaiveDate::from_ymd_opt(2019, 12, 31).unwrap() ); assert_eq!( shift_months(base, -18), NaiveDate::from_ymd_opt(2019, 6, 30).unwrap() ); } #[test] fn test_shift_months_datetime() { let date = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap(); let o_clock = NaiveTime::from_hms_opt(1, 2, 3).unwrap(); let base = NaiveDateTime::new(date, o_clock); assert_eq!( shift_months(base, 0).date(), NaiveDate::from_ymd_opt(2020, 1, 31).unwrap() ); assert_eq!( shift_months(base, 1).date(), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( shift_months(base, 2).date(), NaiveDate::from_ymd_opt(2020, 3, 31).unwrap() ); assert_eq!(shift_months(base, 0).time(), o_clock); assert_eq!(shift_months(base, 1).time(), o_clock); assert_eq!(shift_months(base, 2).time(), o_clock); } #[test] fn test_shift_months_datetime_tz() { let tz = &chrono_tz::Australia::Melbourne; let base = tz.with_ymd_and_hms(2020, 1, 31, 1, 2, 3).single().unwrap(); assert_eq!( shift_months(base, 0), tz.with_ymd_and_hms(2020, 1, 31, 1, 2, 3).single().unwrap() ); assert_eq!( shift_months(base, 1), tz.with_ymd_and_hms(2020, 2, 29, 1, 2, 3).single().unwrap() ); assert_eq!( shift_months(base, 2), tz.with_ymd_and_hms(2020, 3, 31, 1, 2, 3).single().unwrap() ); assert_eq!( shift_months_opt(base, 0).unwrap(), tz.with_ymd_and_hms(2020, 1, 31, 1, 2, 3).single().unwrap() ); assert_eq!( shift_months_opt(base, 1).unwrap(), tz.with_ymd_and_hms(2020, 2, 29, 1, 2, 3).single().unwrap() ); assert_eq!( shift_months_opt(base, 2).unwrap(), tz.with_ymd_and_hms(2020, 3, 31, 1, 2, 3).single().unwrap() ); } #[test] #[should_panic] fn test_shift_months_datetime_to_dst_backward_transition() { let dst_tz = &chrono_tz::Australia::Melbourne; // On Apr 5th 2020 after 02:59:59, clocks were wound back to 02:00:00 making 02:00::00 to // 02:59:59 ambiguous. // if let LocalResult::Single(base) = dst_tz.with_ymd_and_hms(2020, 3, 5, 2, 00, 0) { shift_months(base, 1); // panics } } #[test] fn test_shift_months_opt_datetime_to_dst_backward_transition() { let dst_tz = &chrono_tz::Australia::Melbourne; let base = dst_tz .with_ymd_and_hms(2020, 3, 5, 2, 00, 0) .single() .unwrap(); assert_eq!(None, shift_months_opt(base, 1)) } #[test] #[should_panic] fn test_shift_months_datetime_to_dst_forward_transition() { let dst_tz = &chrono_tz::Australia::Melbourne; // On Oct 4th 2020 after 01:59:59, clocks were advanced to 03:00:00 making 02:00:00 to // 02:59:59 non-existent. // if let LocalResult::Single(base) = dst_tz.with_ymd_and_hms(2020, 9, 4, 2, 00, 0) { shift_months(base, 1); // panics } } #[test] fn test_shift_months_opt_datetime_to_dst_forward_transition() { let dst_tz = &chrono_tz::Australia::Melbourne; let base = dst_tz .with_ymd_and_hms(2020, 9, 4, 2, 00, 0) .single() .unwrap(); assert_eq!(None, shift_months_opt(base, 1)) } #[test] fn test_shift_years() { let base = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); assert_eq!( shift_years(base, 0), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( shift_years(base, 1), NaiveDate::from_ymd_opt(2021, 2, 28).unwrap() ); assert_eq!( shift_years(base, 4), NaiveDate::from_ymd_opt(2024, 2, 29).unwrap() ); assert_eq!( shift_years(base, 80), NaiveDate::from_ymd_opt(2100, 2, 28).unwrap() ); assert_eq!( shift_years(base, -1), NaiveDate::from_ymd_opt(2019, 2, 28).unwrap() ); assert_eq!( shift_years(base, -4), NaiveDate::from_ymd_opt(2016, 2, 29).unwrap() ); assert_eq!( shift_years(base, -20), NaiveDate::from_ymd_opt(2000, 2, 29).unwrap() ); assert_eq!( shift_years(base, -120), NaiveDate::from_ymd_opt(1900, 2, 28).unwrap() ); } #[test] fn test_with_month() { let base = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap(); assert_eq!(with_month(base, 0), None); assert_eq!(with_month(base, 1), Some(base)); assert_eq!( with_month(base, 2).unwrap(), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( with_month(base, 3).unwrap(), NaiveDate::from_ymd_opt(2020, 3, 31).unwrap() ); assert_eq!( with_month(base, 4).unwrap(), NaiveDate::from_ymd_opt(2020, 4, 30).unwrap() ); assert_eq!( with_month(base, 5).unwrap(), NaiveDate::from_ymd_opt(2020, 5, 31).unwrap() ); assert_eq!( with_month(base, 6).unwrap(), NaiveDate::from_ymd_opt(2020, 6, 30).unwrap() ); assert_eq!( with_month(base, 7).unwrap(), NaiveDate::from_ymd_opt(2020, 7, 31).unwrap() ); assert_eq!( with_month(base, 8).unwrap(), NaiveDate::from_ymd_opt(2020, 8, 31).unwrap() ); assert_eq!( with_month(base, 9).unwrap(), NaiveDate::from_ymd_opt(2020, 9, 30).unwrap() ); assert_eq!( with_month(base, 10).unwrap(), NaiveDate::from_ymd_opt(2020, 10, 31).unwrap() ); assert_eq!( with_month(base, 11).unwrap(), NaiveDate::from_ymd_opt(2020, 11, 30).unwrap() ); assert_eq!( with_month(base, 12).unwrap(), NaiveDate::from_ymd_opt(2020, 12, 31).unwrap() ); assert_eq!(with_month(base, 13), None); assert_eq!( with_month(NaiveDate::from_ymd_opt(2021, 1, 31).unwrap(), 2), Some(NaiveDate::from_ymd_opt(2021, 2, 28).unwrap()) ); // Backwards shifts work too assert_eq!( with_month(NaiveDate::from_ymd_opt(2021, 2, 15).unwrap(), 1), Some(NaiveDate::from_ymd_opt(2021, 1, 15).unwrap()) ); } #[test] fn test_with_month_opt() { let tz = &chrono_tz::Australia::Melbourne; let base = tz.with_ymd_and_hms(2020, 1, 31, 0, 0, 0).single().unwrap(); assert_eq!(with_month(base, 0), None); assert_eq!(with_month(base, 1), Some(base)); assert_eq!( with_month(base, 2).unwrap(), tz.with_ymd_and_hms(2020, 2, 29, 0, 0, 0).single().unwrap() ); assert_eq!( with_month(base, 3).unwrap(), tz.with_ymd_and_hms(2020, 3, 31, 0, 0, 0).single().unwrap() ); assert_eq!( with_month(base, 12).unwrap(), tz.with_ymd_and_hms(2020, 12, 31, 0, 0, 0).single().unwrap() ); assert_eq!(with_month(base, 13), None); // Backwards shifts work too assert_eq!( with_month( tz.with_ymd_and_hms(2021, 2, 15, 0, 0, 0).single().unwrap(), 1 ), Some(tz.with_ymd_and_hms(2021, 1, 15, 0, 0, 0).single().unwrap()) ); } #[test] fn test_with_year() { let base = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); assert_eq!( with_year(base, 2024), NaiveDate::from_ymd_opt(2024, 2, 29).unwrap() ); assert_eq!( with_year(base, 2021), NaiveDate::from_ymd_opt(2021, 2, 28).unwrap() ); assert_eq!( with_year(base, 2020), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap() ); assert_eq!( with_year(base, 2019), NaiveDate::from_ymd_opt(2019, 2, 28).unwrap() ); assert_eq!( with_year(base, 2016), NaiveDate::from_ymd_opt(2016, 2, 29).unwrap() ); } } chronoutil-0.2.7/src/lib.rs000064400000000000000000000131001046102023000137160ustar 00000000000000#![warn(missing_docs)] //! # Chronoutil provides powerful extensions to rust's Chrono crate //! //! ChronoUtil provides the following utilities: //! - `RelativeDuration`: extending Chrono's `Duration` to add months and years //! - `DateRule`: 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](https://github.com/dateutil/dateutil) //! and provides a similar API, but with less of the niche functionality. //! ## 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. //! //! ```rust //! # use chrono::{NaiveDate}; //! # use chronoutil::{RelativeDuration}; //! 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): //! //! ```rust //! # use chrono::{NaiveDate}; //! # use chronoutil::{RelativeDuration}; //! 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()); //! ``` //! //! ### 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: //! //! ```rust //! # use chrono::NaiveDate; //! # use chronoutil::DateRule; //! 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_day] 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_: //! //! ```rust //! # use chrono::NaiveDate; //! # use chronoutil::RelativeDuration; //! //! let d1 = (NaiveDate::from_ymd_opt(2020, 1, 31).unwrap() + RelativeDuration::months(1)) //! + RelativeDuration::months(1); //! let d2 = NaiveDate::from_ymd_opt(2020, 1, 31).unwrap() //! + (RelativeDuration::months(1) + RelativeDuration::months(1)); //! //! 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: //! ```rust //! # use chrono::NaiveDate; //! # use chronoutil::{RelativeDuration, DateRule}; //! 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()); //! ``` extern crate chrono; pub mod delta; pub mod relative_duration; pub mod rule; pub use relative_duration::RelativeDuration; pub use rule::DateRule; // Utility functions may be useful for others pub use delta::{is_leap_year, shift_months, shift_years, with_day, with_month, with_year}; chronoutil-0.2.7/src/relative_duration/parse.rs000064400000000000000000000300731046102023000200120ustar 00000000000000use super::RelativeDuration; use chrono::Duration; use std::{convert::TryInto, fmt::Write}; fn dhmsn_to_duration( days: i64, hours: i64, minutes: i64, seconds: i64, nanos: u32, ) -> Option { Duration::new( days.checked_mul(24)? .checked_add(hours)? .checked_mul(60)? .checked_add(minutes)? .checked_mul(60)? .checked_add(seconds)?, nanos, ) } fn get_terminated>( input: &str, terminator: char, ) -> Result<(&str, T), String> { if let Some((int_string, remainder)) = input.split_once(terminator) { let int = int_string.parse::().map_err(|_| { format!( "{} is not a valid {}", int_string, std::any::type_name::() ) })?; Ok((remainder, int)) } else { Ok((input, 0.into())) } } fn get_terminated_decimal(input: &str, terminator: char) -> Result<(&str, i64, u32), String> { if let Some((decimal_string, remainder)) = input.split_once(terminator) { let (int_string, fraction_string) = decimal_string.split_once('.').unwrap_or_else(|| { decimal_string // if no '.' was found, look for ',', as both are valid decimal separators in iso 8601 .split_once(',') // if neither is found take the whole string as the integer part, with no fraction .unwrap_or((decimal_string, "")) }); let int = int_string .parse::() .map_err(|_| format!("{} is not a valid i64", int_string))?; let fraction = if fraction_string.is_empty() { 0 } else { fraction_string .chars() // right pad with zeros .chain(std::iter::repeat('0')) // truncate to 9 chars, since we only support nanosecond resolution .take(9) .collect::() .parse::() .map_err(|_| format!("{} is not a valid u32", fraction_string))? }; // special handling for case of nonzero nanoseconds on a negative duration if decimal_string.starts_with('-') && fraction != 0 { Ok(( remainder, int - 1, (-(fraction as i32) + 1_000_000_000).try_into().unwrap(), )) } else { Ok((remainder, int, fraction)) } } else { Ok((input, 0, 0)) } } fn parse_datespec(datespec: &str) -> Result<(i32, i32, i64), String> { let (remainder, years) = get_terminated::(datespec, 'Y')?; let (remainder, months) = get_terminated::(remainder, 'M')?; let (remainder, weeks) = get_terminated::(remainder, 'W')?; let (remainder, days) = get_terminated::(remainder, 'D')?; if !remainder.is_empty() { Err(format!( "trailing characters: {} in datespec: {}", remainder, datespec )) } else { Ok(( years, months, weeks .checked_mul(7) .and_then(|x| x.checked_add(days)) .ok_or_else(|| "integer overflow on constructing duration".to_string())?, )) } } fn parse_timespec(timespec: &str) -> Result<(i64, i64, i64, u32), String> { let (remainder, hours) = get_terminated::(timespec, 'H')?; let (remainder, mins) = get_terminated::(remainder, 'M')?; let (remainder, secs, nanos) = get_terminated_decimal(remainder, 'S')?; if !remainder.is_empty() { Err(format!( "trailing characters: {} in timespec: {}", remainder, timespec )) } else { Ok((hours, mins, secs, nanos)) } } impl RelativeDuration { /// Parses an [ISO 8601 duration string](https://en.wikipedia.org/wiki/ISO_8601#Durations) into /// a [`RelativeDuration`] value. /// /// This supports only duration strings with integer values (i.e `"P1Y"` but not `"P0.5Y"` or /// `"P0,5Y"`), as fractional values cannot be unambiguously represented as a /// [`RelativeDuration`]. The one exception to this is the seconds field, where the fractional /// part is truncated to 9 digits, and parsed as nanoseconds. /// /// # Errors /// /// - Invalid duration string input /// - Fractional values (apart from seconds) in duration string /// /// # Example /// /// ``` /// use chronoutil::RelativeDuration; /// /// assert_eq!( /// RelativeDuration::parse_from_iso8601("P1Y").unwrap(), /// RelativeDuration::years(1), /// ); /// ``` pub fn parse_from_iso8601(input: &str) -> Result { let input = input .strip_prefix('P') .ok_or_else(|| "duration was not prefixed with P".to_string())?; let (datespec, timespec) = input.split_once('T').unwrap_or((input, "")); let (years, months, days) = parse_datespec(datespec)?; let (hours, mins, secs, nanos) = parse_timespec(timespec)?; Ok(RelativeDuration::months( years .checked_mul(12) .and_then(|x| x.checked_add(months)) .ok_or_else(|| "integer overflow on constructing duration".to_string())?, ) .with_duration( dhmsn_to_duration(days, hours, mins, secs, nanos) .ok_or_else(|| "integer overflow on constructing duration".to_string())?, )) } /// Formats a [`RelativeDuration`] value into an /// [ISO 8601 duration string](https://en.wikipedia.org/wiki/ISO_8601#Durations). /// /// # Example /// /// ``` /// use chronoutil::RelativeDuration; /// /// assert_eq!( /// RelativeDuration::years(1).format_to_iso8601(), /// "P1Y", /// ); /// ``` pub fn format_to_iso8601(&self) -> String { let years = self.months as i64 / 12; let months = self.months as i64 % 12; let duration_seconds = self.duration.num_seconds(); let days = duration_seconds / (24 * 60 * 60); let mut remaining_seconds = duration_seconds % (24 * 60 * 60); let hours = remaining_seconds / (60 * 60); remaining_seconds %= 60 * 60; let minutes = remaining_seconds / 60; remaining_seconds %= 60; let subsec_nanos = self.duration.subsec_nanos(); // This awkward handling is needed to represent nanoseconds as a fraction of seconds, // instead of independently, since it must have no sign, and will affect the sign for // seconds. This would be simpler if we could get the Duration.secs and Duration.nanos // directly, but unfortunately chrono only offers Duration::num_seconds and // Duration::subsec_nanos, both of which apply transformations before returning... let (seconds, nanos, push_minus) = if remaining_seconds > 0 && subsec_nanos < 0 { (remaining_seconds - 1, subsec_nanos + 1_000_000_000, false) } else if remaining_seconds < 0 && subsec_nanos > 0 { (remaining_seconds + 1, -subsec_nanos + 1_000_000_000, false) } else if remaining_seconds <= 0 && subsec_nanos < 0 { (remaining_seconds, -subsec_nanos, remaining_seconds == 0) } else { (remaining_seconds, subsec_nanos, false) }; let mut out = String::new(); out.push('P'); [years, months, days] .iter() .zip(['Y', 'M', 'D']) .filter(|x| *x.0 != 0) .fold(&mut out, |out, x| { let _ = write!(out, "{}{}", x.0, x.1); out }); if [hours, minutes, seconds, nanos as i64] .iter() .any(|x| *x != 0) { out.push('T'); } [hours, minutes] .iter() .zip(['H', 'M']) .filter(|x| *x.0 != 0) .fold(&mut out, |out, x| { let _ = write!(out, "{}{}", x.0, x.1); out }); if push_minus { out.push('-'); } if seconds != 0 || nanos != 0 { let _ = write!(out, "{}", seconds); if nanos != 0 { let nanos_str_raw = format!("{:0>9}", nanos); let nanos_str_trimmed = nanos_str_raw.trim_end_matches('0'); out.push('.'); out.push_str(nanos_str_trimmed); } out.push('S'); } out } } #[cfg(test)] mod tests { use proptest::prelude::*; use super::*; #[test] fn test_parse_duration() { [ ( "P1YT1S", RelativeDuration::months(12).with_duration(Duration::seconds(1)), ), ( "P2Y2M2DT2H2M2S", RelativeDuration::months(2 * 12 + 2) .with_duration(dhmsn_to_duration(2, 2, 2, 2, 0).unwrap()), ), ( "P1M", RelativeDuration::months(1).with_duration(Duration::zero()), ), ("PT10M", RelativeDuration::minutes(10)), ("P-1M", RelativeDuration::months(-1)), ("P1W1D", RelativeDuration::days(8)), ( "P1Y-10M-1W3DT3H-6M-1S", RelativeDuration::months(2) .with_duration(dhmsn_to_duration(-4, 3, -6, -1, 0).unwrap()), ), ("P-23M", RelativeDuration::months(-23)), ("PT0.0000000010S", RelativeDuration::nanoseconds(1)), ("PT0.1S", RelativeDuration::nanoseconds(100_000_000)), ( "PT-0.999999999S", RelativeDuration::years(0) .with_duration(dhmsn_to_duration(0, 0, 0, -1, 1).unwrap()), ), ] .iter() .for_each(|(input, expected)| { assert_eq!( RelativeDuration::parse_from_iso8601(input).unwrap(), *expected ) }) } #[test] fn test_format_duration() { [ ( RelativeDuration::months(12).with_duration(Duration::seconds(1)), "P1YT1S", ), ( RelativeDuration::months(2 * 12 + 2) .with_duration(dhmsn_to_duration(2, 2, 2, 2, 0).unwrap()), "P2Y2M2DT2H2M2S", ), ( RelativeDuration::months(1).with_duration(Duration::zero()), "P1M", ), (RelativeDuration::minutes(10), "PT10M"), (RelativeDuration::months(-1), "P-1M"), (RelativeDuration::months(-23), "P-1Y-11M"), (RelativeDuration::nanoseconds(1), "PT0.000000001S"), (RelativeDuration::nanoseconds(100_000_000), "PT0.1S"), ( RelativeDuration::years(0) .with_duration(dhmsn_to_duration(0, 0, 0, -1, 1).unwrap()), "PT-0.999999999S", ), ] .iter() .for_each(|(input, expected)| assert_eq!(input.format_to_iso8601(), *expected)) } proptest! { #[test] fn proptest_format_and_back( months in prop::num::i32::ANY, secs in (i64::MIN/1000)..(i64::MAX/1000), nanos in 0u32..1_000_000_000 ) { let d = RelativeDuration::months(months).with_duration(Duration::new(secs, nanos).unwrap()); prop_assert_eq!(d, RelativeDuration::parse_from_iso8601(&(d.format_to_iso8601())).unwrap()); } #[test] fn proptest_parse_and_back( s in r"P(?:[1-9][0-9]{0,7}Y)?(?:(?:[1-9]|1[0-1])M)?(?:(?:[1-9]|[1-2][0-9])D)?(?:T(?:(?:[1-9]|1[0-9]|2[0-3])H)(?:(?:[1-9]|[1-5][0-9])M)(?:(?:(?:[1-9]|[1-5][0-9])|(?:(?:[0-9]|[1-5][0-9])\.[0-9]{0,8}[1-9]))S))?", ) { prop_assert_eq!(s.clone(), RelativeDuration::parse_from_iso8601(&s).unwrap().format_to_iso8601()); } #[test] fn proptest_parse_doesnt_panic(s in r"//PC*") { let _ = RelativeDuration::parse_from_iso8601(&s); } } } chronoutil-0.2.7/src/relative_duration.rs000064400000000000000000000307121046102023000167000ustar 00000000000000//! Implements a RelativeDuration extending Chrono's Duration to shift by months and years. use core::ops::{Add, Div, Mul, Neg, Sub}; use std::time::Duration as StdDuration; use chrono::{Date, DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone}; use super::delta::shift_months; mod parse; /// Relative time duration extending Chrono's Duration. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] pub struct RelativeDuration { months: i32, // Sorry, cosmologists.. duration: Duration, } impl From for RelativeDuration { /// Makes a new `RelativeDuration` from a `chrono::Duration`. #[inline] fn from(item: Duration) -> Self { RelativeDuration { months: 0, duration: item, } } } impl From for RelativeDuration { /// Makes a new `RelativeDuration` from a std `Duration`. #[inline] fn from(item: StdDuration) -> Self { RelativeDuration::from( Duration::from_std(item).expect("RelativeDuration::from_std OutOfRangeError"), ) } } impl RelativeDuration { /// Makes a new `RelativeDuration` with given number of years. /// /// Equivalent to `RelativeDuration::months(years * 12)` with overflow checks. /// Panics when the duration is out of bounds. #[inline] pub fn years(years: i32) -> RelativeDuration { let months = years .checked_mul(12) .expect("RelativeDuration::years out of bounds"); RelativeDuration::months(months) } /// Makes a new `RelativeDuration` with given number of months. /// Panics when the duration is out of bounds. #[inline] pub fn months(months: i32) -> RelativeDuration { RelativeDuration { months, duration: Duration::zero(), } } /// Makes a new `RelativeDuration` with given number of weeks. /// Panics when the duration is out of bounds. #[inline] pub fn weeks(weeks: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::weeks(weeks), } } /// Makes a new `RelativeDuration` with given number of days. /// Panics when the duration is out of bounds. #[inline] pub fn days(days: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::days(days), } } /// Makes a new `RelativeDuration` with given number of hours. /// Panics when the duration is out of bounds. #[inline] pub fn hours(hours: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::hours(hours), } } /// Makes a new `RelativeDuration` with given number of minutes. /// Panics when the duration is out of bounds. #[inline] pub fn minutes(minutes: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::minutes(minutes), } } /// Makes a new `RelativeDuration` with given number of seconds. /// Panics when the duration is out of bounds. #[inline] pub fn seconds(seconds: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::seconds(seconds), } } /// Makes a new `RelativeDuration` with given number of milliseconds. #[inline] pub fn milliseconds(milliseconds: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::milliseconds(milliseconds), } } /// Makes a new `RelativeDuration` with given number of microseconds. #[inline] pub fn microseconds(microseconds: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::microseconds(microseconds), } } /// Makes a new `RelativeDuration` with given number of nanoseconds. #[inline] pub fn nanoseconds(nanos: i64) -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::nanoseconds(nanos), } } /// Update the `Duration` part of the current `RelativeDuration`. #[inline] pub fn with_duration(self, duration: Duration) -> RelativeDuration { RelativeDuration { months: self.months, duration, } } /// A `RelativeDuration` representing zero. #[inline] pub fn zero() -> RelativeDuration { RelativeDuration { months: 0, duration: Duration::zero(), } } /// Returns true if the duration equals RelativeDuration::zero(). #[inline] pub fn is_zero(&self) -> bool { self.months == 0 && self.duration.is_zero() } } impl Neg for RelativeDuration { type Output = RelativeDuration; #[inline] fn neg(self) -> RelativeDuration { RelativeDuration { months: -self.months, duration: -self.duration, } } } impl Add for RelativeDuration { type Output = RelativeDuration; #[inline] fn add(self, rhs: RelativeDuration) -> RelativeDuration { RelativeDuration { months: self.months + rhs.months, duration: self.duration + rhs.duration, } } } impl Add for RelativeDuration { type Output = RelativeDuration; #[inline] fn add(self, rhs: Duration) -> RelativeDuration { self + RelativeDuration { months: 0, duration: rhs, } } } impl Add for Duration { type Output = RelativeDuration; #[inline] fn add(self, rhs: RelativeDuration) -> RelativeDuration { rhs + self } } impl Sub for RelativeDuration { type Output = RelativeDuration; #[inline] fn sub(self, rhs: RelativeDuration) -> RelativeDuration { self + (-rhs) } } impl Sub for Duration { type Output = RelativeDuration; #[inline] fn sub(self, rhs: RelativeDuration) -> RelativeDuration { -rhs + self } } impl Sub for RelativeDuration { type Output = RelativeDuration; #[inline] fn sub(self, rhs: Duration) -> RelativeDuration { self + (-rhs) } } impl Mul for RelativeDuration { type Output = RelativeDuration; #[inline] fn mul(self, rhs: i32) -> RelativeDuration { RelativeDuration { months: self.months * rhs, duration: self.duration * rhs, } } } impl Div for RelativeDuration { type Output = RelativeDuration; #[inline] fn div(self, rhs: i32) -> RelativeDuration { RelativeDuration { months: self.months / rhs, duration: self.duration / rhs, } } } // The following is just copy-pasta, mostly because we // can't impl Add for T with T: Datelike impl Add for NaiveDate { type Output = NaiveDate; #[inline] fn add(self, rhs: RelativeDuration) -> NaiveDate { shift_months(self, rhs.months) + rhs.duration } } impl Add for NaiveDateTime { type Output = NaiveDateTime; #[inline] fn add(self, rhs: RelativeDuration) -> NaiveDateTime { shift_months(self, rhs.months) + rhs.duration } } impl Add for Date where Tz: TimeZone, { type Output = Date; #[inline] fn add(self, rhs: RelativeDuration) -> Date { shift_months(self, rhs.months) + rhs.duration } } impl Add for DateTime where Tz: TimeZone, { type Output = DateTime; #[inline] fn add(self, rhs: RelativeDuration) -> DateTime { shift_months(self, rhs.months) + rhs.duration } } impl Sub for NaiveDate { type Output = NaiveDate; #[inline] fn sub(self, rhs: RelativeDuration) -> NaiveDate { self + (-rhs) } } impl Sub for NaiveDateTime { type Output = NaiveDateTime; #[inline] fn sub(self, rhs: RelativeDuration) -> NaiveDateTime { self + (-rhs) } } impl Sub for Date where Tz: TimeZone, { type Output = Date; #[inline] fn sub(self, rhs: RelativeDuration) -> Date { self + (-rhs) } } impl Sub for DateTime where Tz: TimeZone, { type Output = DateTime; #[inline] fn sub(self, rhs: RelativeDuration) -> DateTime { self + (-rhs) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_duration_arithmetic() { let x = RelativeDuration { months: 5 * 12 + 7, duration: Duration::seconds(100), }; let y = RelativeDuration { months: 3 * 12 + 6, duration: Duration::seconds(300), }; let z = Duration::days(100); assert_eq!( x + y, RelativeDuration { months: 9 * 12 + 1, duration: Duration::seconds(400) } ); assert_eq!( x - y, RelativeDuration { months: 2 * 12 + 1, duration: Duration::seconds(-200) } ); assert_eq!( x + z, RelativeDuration { months: 5 * 12 + 7, duration: Duration::days(100) + Duration::seconds(100) } ); assert_eq!(y + x, y + x, "Addition should be symmetric"); assert_eq!(x - y, -(y - x), "Subtraction should be anti-symmetric"); assert_eq!(y + z, z + y, "Addition should be symmetric"); assert_eq!(y - z, -(z - y), "Subtraction should be anti-symmetric"); assert_eq!( x / 2, RelativeDuration { months: 5 * 6 + 3, duration: Duration::seconds(50) } ); assert_eq!( x * 2, RelativeDuration { months: 10 * 12 + 14, duration: Duration::seconds(200) } ); } #[test] fn test_date_arithmetic() { let base = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); assert_eq!( base + RelativeDuration { months: 24, duration: Duration::zero() }, NaiveDate::from_ymd_opt(2022, 2, 28).unwrap() ); assert_eq!( base + RelativeDuration { months: 48, duration: Duration::zero() }, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap() ); let not_leap = NaiveDate::from_ymd_opt(2020, 2, 28).unwrap(); let tricky_delta = RelativeDuration { months: 24, duration: Duration::days(1), }; assert_eq!( base + tricky_delta, NaiveDate::from_ymd_opt(2022, 3, 1).unwrap() ); assert_eq!(base + tricky_delta, not_leap + tricky_delta); } #[test] fn test_date_negative_arithmetic() { let base = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); assert_eq!( base - RelativeDuration { months: 24, duration: Duration::zero() }, NaiveDate::from_ymd_opt(2018, 2, 28).unwrap() ); assert_eq!( base - RelativeDuration { months: 48, duration: Duration::zero() }, NaiveDate::from_ymd_opt(2016, 2, 29).unwrap() ); let not_leap = NaiveDate::from_ymd_opt(2020, 2, 28).unwrap(); let tricky_delta = RelativeDuration { months: 24, duration: Duration::days(-1), }; assert_eq!( base - tricky_delta, NaiveDate::from_ymd_opt(2018, 3, 1).unwrap() ); assert_eq!(base - tricky_delta, not_leap - tricky_delta); } #[test] fn test_constructors() { assert_eq!(RelativeDuration::years(5), RelativeDuration::months(60)); assert_eq!(RelativeDuration::weeks(5), RelativeDuration::days(35)); assert_eq!(RelativeDuration::days(5), RelativeDuration::hours(120)); assert_eq!(RelativeDuration::hours(5), RelativeDuration::minutes(300)); assert_eq!(RelativeDuration::minutes(5), RelativeDuration::seconds(300)); assert_eq!( RelativeDuration::months(1).with_duration(Duration::weeks(3)), RelativeDuration { months: 1, duration: Duration::weeks(3) }, ); } } chronoutil-0.2.7/src/rule.rs000064400000000000000000000406411046102023000141310ustar 00000000000000//! Implements `DateRule` - an iterator yielding evenly spaced dates. use std::iter::Iterator; use super::delta::with_day; use super::relative_duration::RelativeDuration; use chrono::{Date, DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone}; /// DateRule is an iterator for yielding evenly spaced dates /// according to a given RelativeDuration. It avoids some /// of the pitfalls that naive usage of RelativeDuration /// can incur. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct DateRule { freq: RelativeDuration, start: D, end: Option, count: Option, rolling_day: Option, _current_count: usize, } impl DateRule where D: Datelike + Copy, { /// Creates a new `DateRule` from an initial date and relative duration. #[inline] pub fn new(start: D, freq: RelativeDuration) -> Self { Self { freq, start, end: None, count: None, rolling_day: None, _current_count: 0, } } /// Creates a `DateRule` yielding dates one second appart. #[inline] pub fn secondly(from: D) -> Self { Self::new(from, RelativeDuration::seconds(1)) } /// Creates a `DateRule` yielding dates one minute appart. #[inline] pub fn minutely(from: D) -> Self { Self::new(from, RelativeDuration::minutes(1)) } /// Creates a `DateRule` yielding dates one hour appart. #[inline] pub fn hourly(from: D) -> Self { Self::new(from, RelativeDuration::hours(1)) } /// Creates a `DateRule` yielding dates one day appart. #[inline] pub fn daily(from: D) -> Self { Self::new(from, RelativeDuration::days(1)) } /// Creates a `DateRule` yielding dates one week appart. #[inline] pub fn weekly(from: D) -> Self { Self::new(from, RelativeDuration::weeks(1)) } /// Creates a `DateRule` yielding dates one month appart. /// Ambiguous month-ends are shifted backwards as necessary. #[inline] pub fn monthly(from: D) -> Self { Self::new(from, RelativeDuration::months(1)) } /// Creates a `DateRule` yielding dates one year appart. /// Ambiguous month-ends are shifted backwards as necessary. #[inline] pub fn yearly(from: D) -> Self { Self::new(from, RelativeDuration::years(1)) } /// Limits the `DateRule` to a given number of dates. pub fn with_count(&self, number: usize) -> Self { Self { freq: self.freq, start: self.start, end: None, count: Some(number), rolling_day: self.rolling_day, _current_count: 0, } } /// Limits the `DateRule` to an extremal date (exclusive). /// /// If using a `RelativeDuration` which shifts dates backwards, the `end` date should /// be before the current date. /// /// WARNING: a forward-shifting duration with an end-date before the initial date /// will result in an iterator which does not terminate. pub fn with_end(&self, end: D) -> Self { Self { freq: self.freq, start: self.start, end: Some(end), count: None, rolling_day: self.rolling_day, _current_count: 0, } } /// Ensure the `DateRule` yields new dates which are *always* fall on the /// given rolling day (modulo backwards shifting for month ends). Returns /// Err if the rolling day is not in the range 1-31. /// /// For example: /// ```rust /// # use chrono::NaiveDate; /// # use chronoutil::DateRule; /// let start = NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(); /// let mut rule = DateRule::monthly(start).with_rolling_day(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()); /// assert_eq!(rule.next().unwrap(), NaiveDate::from_ymd_opt(2020, 4, 30).unwrap()); /// assert_eq!(rule.next().unwrap(), NaiveDate::from_ymd_opt(2020, 5, 31).unwrap()); /// // etc. /// ``` /// /// It produces values equivalent to /// ```ignore /// rule.map(|d| with_day(d, rolling_day).unwrap()) /// ``` pub fn with_rolling_day(&self, rolling_day: u32) -> Result { if rolling_day == 0 || rolling_day > 31 { Err(format!("Rolling day {} not in range 1-31", rolling_day)) } else { Ok(Self { freq: self.freq, start: self.start, end: self.end, count: self.count, rolling_day: Some(rolling_day), _current_count: self._current_count, }) } } } // The following is just copy-pasta, mostly because we // can't impl Add for T with T: Datelike impl Iterator for DateRule { type Item = NaiveDate; fn next(&mut self) -> Option { if self.count.is_some() && self._current_count >= self.count.unwrap() { return None; } let mut current_date = self.start + self.freq * self._current_count as i32; if let Some(rolling_day) = self.rolling_day { current_date = with_day(current_date, rolling_day).unwrap(); } if let Some(end) = &self.end { if (*end >= self.start && current_date >= *end) || (*end < self.start && current_date <= *end) { return None; } } self._current_count += 1; Some(current_date) } } impl Iterator for DateRule { type Item = NaiveDateTime; fn next(&mut self) -> Option { if self.count.is_some() && self._current_count >= self.count.unwrap() { return None; } let mut current_date = self.start + self.freq * self._current_count as i32; if let Some(rolling_day) = self.rolling_day { current_date = with_day(current_date, rolling_day).unwrap(); } if let Some(end) = &self.end { if (*end >= self.start && current_date >= *end) || (*end < self.start && current_date <= *end) { return None; } } self._current_count += 1; Some(current_date) } } impl Iterator for DateRule> where Tz: TimeZone, { type Item = Date; fn next(&mut self) -> Option { if self.count.is_some() && self._current_count >= self.count.unwrap() { return None; } let mut current_date = self.start.clone() + self.freq * self._current_count as i32; if let Some(rolling_day) = self.rolling_day { current_date = with_day(current_date, rolling_day).unwrap(); } if let Some(end) = &self.end { if (*end >= self.start && current_date >= *end) || (*end < self.start && current_date <= *end) { return None; } } self._current_count += 1; Some(current_date) } } impl Iterator for DateRule> where Tz: TimeZone, { type Item = DateTime; fn next(&mut self) -> Option { if self.count.is_some() && self._current_count >= self.count.unwrap() { return None; } let mut current_date = self.start.clone() + self.freq * self._current_count as i32; if let Some(rolling_day) = self.rolling_day { current_date = with_day(current_date, rolling_day).unwrap(); } if let Some(end) = &self.end { if (*end >= self.start && current_date >= *end) || (*end < self.start && current_date <= *end) { return None; } } self._current_count += 1; Some(current_date) } } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, NaiveDateTime, NaiveTime}; #[test] fn test_rrule_with_date() { let start = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); // Seconds, hours, minutes etc for (i, date) in DateRule::secondly(start) .with_count(24 * 60 * 60 * 2) .enumerate() { if i < 24 * 60 * 60 { assert_eq!(date, start, "Expected {} seconds to be on first day", i); } else { assert_eq!( date, start + Duration::days(1), "Expected {} seconds to be on second day", i ); } } for (i, date) in DateRule::minutely(start) .with_count(24 * 60 * 2) .enumerate() { if i < 24 * 60 { assert_eq!(date, start, "Expected {} minutes to be on first day", i); } else { assert_eq!( date, start + Duration::days(1), "Expected {} minutes to be on second day", i ); } } for (i, date) in DateRule::hourly(start).with_count(24 * 2).enumerate() { if i < 24 { assert_eq!(date, start, "Expected {} hours to be on first day", i); } else { assert_eq!( date, start + Duration::days(1), "Expected {} hours to be on second day", i ); } } // Days, weeks let days: Vec = DateRule::daily(start).with_count(5).collect(); assert_eq!(days[0], start, "DateRule should start at the initial day"); assert_eq!( days[1], start + Duration::days(1), "DateRule should increment in days" ); assert_eq!( days.len(), 5, "DateRule should finish before the count is up" ); let finish = NaiveDate::from_ymd_opt(2020, 1, 29).unwrap(); let weeks: Vec = DateRule::weekly(start).with_end(finish).collect(); assert_eq!(weeks[0], start, "DateRule should start at the initial day"); assert_eq!( weeks[1], start + Duration::days(7), "DateRule should increment in weeks" ); assert_eq!( weeks.len(), 4, "DateRule should finish before the final day" ); // Months, years let interesting = NaiveDate::from_ymd_opt(2020, 1, 30).unwrap(); // The day will change each month let months: Vec = DateRule::monthly(interesting).with_count(5).collect(); assert_eq!( months[0], interesting, "DateRule should start at the initial day" ); assert_eq!( months[1], NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), "DateRule should handle Feb" ); assert_eq!( months[2], NaiveDate::from_ymd_opt(2020, 3, 30).unwrap(), "DateRule should not loose days" ); assert_eq!( months.len(), 5, "DateRule should finish before the count is up" ); let years: Vec = DateRule::yearly(interesting).with_count(3).collect(); assert_eq!( years[0], interesting, "DateRule should start at the initial day" ); assert_eq!( years[1], NaiveDate::from_ymd_opt(2021, 1, 30).unwrap(), "DateRule should increment in years" ); assert_eq!( years.len(), 3, "DateRule should finish before the count is up" ); } #[test] fn test_rrule_with_datetime() { // Seconds let o_clock = NaiveTime::from_hms_opt(1, 2, 3).unwrap(); let day = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); let start = NaiveDateTime::new(day, o_clock); let seconds_passed = 60 * 60 + 2 * 60 + 3; for (i, date) in DateRule::secondly(start) .with_count(24 * 60 * 60 * 2) .enumerate() { if i > 0 { assert!(date > start, "Time should increase"); } if i < 24 * 60 * 60 - seconds_passed { assert_eq!( date.date(), day, "Expected {} seconds to be on first day", date ); } else if i < 2 * 24 * 60 * 60 - seconds_passed { assert_eq!( date.date(), day + Duration::days(1), "Expected {} to be on second day", date ); } else { assert_eq!( date.date(), day + Duration::days(2), "Expected {} to be on third day", date ); } } // Months let interesting = NaiveDate::from_ymd_opt(2020, 1, 30).unwrap(); // The day will change each month let istart = NaiveDateTime::new(interesting, o_clock); let months: Vec = DateRule::monthly(istart).with_count(5).collect(); assert_eq!( months[0], istart, "DateRule should start at the initial day" ); assert_eq!( months[1].date(), NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), "DateRule should handle Feb" ); assert_eq!(months[1].time(), o_clock, "Time should remain the same"); assert_eq!( months[2].date(), NaiveDate::from_ymd_opt(2020, 3, 30).unwrap(), "DateRule should not loose days" ); assert_eq!(months[2].time(), o_clock, "Time should remain the same"); } #[test] fn test_rrule_edge_cases() { let start = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); // Zero count let mut dates: Vec = DateRule::daily(start).with_count(0).collect(); assert_eq!(dates.len(), 0); // End equals start dates = DateRule::daily(start).with_end(start).collect(); assert_eq!(dates.len(), 0); // End before start // TODO: the only way to know to stop is to determine the forward/backwardness of the duration. // This is a concept which is ill formed (e.g. +1 month - 30 days) so needs thought. // dates = DateRule::daily(start).with_end(start - Duration::days(1)).collect(); // assert_eq!(dates.len(), 0); } #[test] fn test_backwards_rrule() { let start = NaiveDate::from_ymd_opt(2020, 3, 31).unwrap(); let end = NaiveDate::from_ymd_opt(2019, 12, 31).unwrap(); let freq = RelativeDuration::months(-1); let dates1: Vec = DateRule::new(start, freq).with_count(3).collect(); assert_eq!(dates1.len(), 3); assert_eq!(dates1[0], NaiveDate::from_ymd_opt(2020, 3, 31).unwrap()); assert_eq!(dates1[1], NaiveDate::from_ymd_opt(2020, 2, 29).unwrap()); assert_eq!(dates1[2], NaiveDate::from_ymd_opt(2020, 1, 31).unwrap()); let dates2: Vec = DateRule::new(start, freq).with_end(end).collect(); assert_eq!(dates1.len(), dates2.len()); for k in 0..dates1.len() { assert_eq!(dates1[k], dates2[k]); } } #[test] fn test_long_running_rules() { // Sanity tests for long-running shifts with various start months for month in &[1, 3, 5, 7, 8, 10, 12] { let start = NaiveDate::from_ymd_opt(2020, *month as u32, 31).unwrap(); let mut rule = DateRule::monthly(start); for _ in 0..120 { let shifted = rule.next().unwrap(); if shifted.month() == 1 { assert_eq!(shifted.day(), 31) } else if shifted.month() == 4 { assert_eq!(shifted.day(), 30) } } let freq = RelativeDuration::months(-1); let mut rule = DateRule::new(start, freq); for _ in 0..120 { let shifted = rule.next().unwrap(); if shifted.month() == 1 { assert_eq!(shifted.day(), 31) } else if shifted.month() == 4 { assert_eq!(shifted.day(), 30) } } } } }