icu_calendar 2.2.1

Date APIs for Gregorian and non-Gregorian calendars
Documentation
// This file is part of ICU4X. For terms of use, please see the file
// called LICENSE at the top level of the ICU4X source tree
// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).

use crate::cal::abstract_gregorian::{
    impl_with_abstract_gregorian, AbstractGregorian, GregorianYears,
};
use crate::calendar_arithmetic::ArithmeticDate;
use crate::error::UnknownEraError;
use crate::{types, Date, RangeError};
use tinystr::tinystr;

/// The [ISO-8601 Calendar](https://en.wikipedia.org/wiki/ISO_8601#Dates)
///
/// This calendar is identical to the [`Gregorian`](super::Gregorian) calendar,
/// except that it uses a single `default` era instead of `bce` and `ce`.
///
/// This corresponds to the `"iso8601"` [CLDR calendar](https://unicode.org/reports/tr35/#UnicodeCalendarIdentifier).
///
/// # Era codes
///
/// This calendar uses a single era: `default`
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[allow(clippy::exhaustive_structs)] // this type is stable
pub struct Iso;

impl_with_abstract_gregorian!(Iso, IsoDateInner, IsoEra, _x, IsoEra);

#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct IsoEra;

impl GregorianYears for IsoEra {
    fn extended_from_era_year(
        &self,
        era: Option<&[u8]>,
        year: i32,
    ) -> Result<i32, UnknownEraError> {
        match era {
            Some(b"default") | None => Ok(year),
            Some(_) => Err(UnknownEraError),
        }
    }

    fn era_year_from_extended(&self, extended_year: i32, _month: u8, _day: u8) -> types::EraYear {
        types::EraYear {
            era_index: Some(0),
            era: tinystr!(16, "default"),
            year: extended_year,
            extended_year,
            ambiguity: types::YearAmbiguity::Unambiguous,
        }
    }

    fn debug_name(&self) -> &'static str {
        "ISO"
    }
}

impl Date<Iso> {
    /// Construct a new ISO [`Date`].
    ///
    /// Years are arithmetic, meaning there is a year 0 preceded by negative years, with a
    /// valid range of `-9999..=9999`.
    ///
    /// ```rust
    /// use icu::calendar::Date;
    ///
    /// let date_iso = Date::try_new_iso(1970, 1, 2)
    ///     .expect("Failed to initialize ISO Date instance.");
    ///
    /// assert_eq!(date_iso.era_year().year, 1970);
    /// assert_eq!(date_iso.month().ordinal, 1);
    /// assert_eq!(date_iso.day_of_month().0, 2);
    /// ```
    pub fn try_new_iso(year: i32, month: u8, day: u8) -> Result<Date<Iso>, RangeError> {
        ArithmeticDate::from_year_month_day(year, month, day, &AbstractGregorian(IsoEra))
            .map(ArithmeticDate::cast)
            .map(IsoDateInner)
            .map(|i| Date::from_raw(i, Iso))
    }
}

impl Iso {
    /// Construct a new ISO Calendar
    pub fn new() -> Self {
        Self
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::{
        calendar_arithmetic::{CONSTRUCTOR_YEAR_RANGE, VALID_RD_RANGE},
        types::{DateDuration, RataDie, Weekday},
    };

    #[test]
    fn iso_overflow() {
        #[derive(Debug)]
        struct TestCase {
            year: i32,
            month: u8,
            day: u8,
            rd: RataDie,
            invalid_ymd: bool,
            clamping_rd: bool,
        }
        let cases = [
            // Clamping RD
            TestCase {
                year: -999999,
                month: 1,
                day: 1,
                rd: *VALID_RD_RANGE.start() - 100000,
                invalid_ymd: true,
                clamping_rd: true,
            },
            // Lowest allowed RD
            TestCase {
                year: -999999,
                month: 1,
                day: 1,
                rd: *VALID_RD_RANGE.start(),
                invalid_ymd: true,
                clamping_rd: false,
            },
            // Lowest allowed YMD
            TestCase {
                year: *CONSTRUCTOR_YEAR_RANGE.start(),
                month: 1,
                day: 1,
                rd: RataDie::new(-3652424),
                invalid_ymd: false,
                clamping_rd: false,
            },
            // Highest allowed YMD
            TestCase {
                year: *CONSTRUCTOR_YEAR_RANGE.end(),
                month: 12,
                day: 31,
                rd: RataDie::new(3652059),
                invalid_ymd: false,
                clamping_rd: false,
            },
            // Highest allowed RD
            TestCase {
                year: 999999,
                month: 12,
                day: 31,
                rd: *VALID_RD_RANGE.end(),
                invalid_ymd: true,
                clamping_rd: false,
            },
            // Clamping RD
            TestCase {
                year: 999999,
                month: 12,
                day: 31,
                rd: *VALID_RD_RANGE.end() + 100000,
                invalid_ymd: true,
                clamping_rd: true,
            },
        ];

        for case in cases {
            let date_from_rd = Date::from_rata_die(case.rd, Iso);
            let date_from_ymd = Date::try_new_iso(case.year, case.month, case.day);

            if !case.clamping_rd {
                assert_eq!(date_from_rd.to_rata_die(), case.rd, "{:?}", case);
            } else {
                assert_ne!(date_from_rd.to_rata_die(), case.rd, "{:?}", case);
            }

            if !case.invalid_ymd {
                assert_eq!(date_from_ymd.unwrap().to_rata_die(), case.rd, "{case:?}");
            } else {
                assert_eq!(
                    date_from_ymd,
                    Err(RangeError {
                        field: "year",
                        value: case.year,
                        min: *CONSTRUCTOR_YEAR_RANGE.start(),
                        max: *CONSTRUCTOR_YEAR_RANGE.end()
                    }),
                    "{case:?}"
                )
            }
            assert_eq!(
                (
                    date_from_rd.era_year().year,
                    date_from_rd.month().number(),
                    date_from_rd.day_of_month().0
                ),
                (case.year, case.month, case.day),
                "{case:?}"
            );
        }
    }

    #[test]
    fn test_weekday() {
        // June 23, 2021 is a Wednesday
        assert_eq!(
            Date::try_new_iso(2021, 6, 23).unwrap().weekday(),
            Weekday::Wednesday,
        );
        // Feb 2, 1983 was a Wednesday
        assert_eq!(
            Date::try_new_iso(1983, 2, 2).unwrap().weekday(),
            Weekday::Wednesday,
        );
        // Jan 21, 2021 was a Tuesday
        assert_eq!(
            Date::try_new_iso(2020, 1, 21).unwrap().weekday(),
            Weekday::Tuesday,
        );
    }

    #[test]
    fn test_day_of_year() {
        // June 23, 2021 was day 174
        assert_eq!(Date::try_new_iso(2021, 6, 23).unwrap().day_of_year().0, 174,);
        // June 23, 2020 was day 175
        assert_eq!(Date::try_new_iso(2020, 6, 23).unwrap().day_of_year().0, 175,);
        // Feb 2, 1983 was a Wednesday
        assert_eq!(Date::try_new_iso(1983, 2, 2).unwrap().day_of_year().0, 33,);
    }

    #[test]
    fn test_offset() {
        let today = Date::try_new_iso(2021, 6, 23).unwrap();
        let today_plus_5000 = Date::try_new_iso(2035, 3, 2).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(5000), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_5000);

        let today = Date::try_new_iso(2021, 6, 23).unwrap();
        let today_minus_5000 = Date::try_new_iso(2007, 10, 15).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(-5000), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_5000);
    }

    #[test]
    fn test_offset_at_month_boundary() {
        let today = Date::try_new_iso(2020, 2, 28).unwrap();
        let today_plus_2 = Date::try_new_iso(2020, 3, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(2), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_2);

        let today = Date::try_new_iso(2020, 2, 28).unwrap();
        let today_plus_3 = Date::try_new_iso(2020, 3, 2).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(3), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_3);

        let today = Date::try_new_iso(2020, 2, 28).unwrap();
        let today_plus_1 = Date::try_new_iso(2020, 2, 29).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(1), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_1);

        let today = Date::try_new_iso(2019, 2, 28).unwrap();
        let today_plus_2 = Date::try_new_iso(2019, 3, 2).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(2), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_2);

        let today = Date::try_new_iso(2019, 2, 28).unwrap();
        let today_plus_1 = Date::try_new_iso(2019, 3, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(1), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_1);

        let today = Date::try_new_iso(2020, 3, 1).unwrap();
        let today_minus_1 = Date::try_new_iso(2020, 2, 29).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_days(-1), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_1);
    }

    #[test]
    fn test_offset_handles_negative_month_offset() {
        let today = Date::try_new_iso(2020, 3, 1).unwrap();
        let today_minus_2_months = Date::try_new_iso(2020, 1, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_months(-2), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_2_months);

        let today = Date::try_new_iso(2020, 3, 1).unwrap();
        let today_minus_4_months = Date::try_new_iso(2019, 11, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_months(-4), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_4_months);

        let today = Date::try_new_iso(2020, 3, 1).unwrap();
        let today_minus_24_months = Date::try_new_iso(2018, 3, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_months(-24), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_24_months);

        let today = Date::try_new_iso(2020, 3, 1).unwrap();
        let today_minus_27_months = Date::try_new_iso(2017, 12, 1).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_months(-27), Default::default())
            .unwrap();
        assert_eq!(offset, today_minus_27_months);
    }

    #[test]
    fn test_offset_handles_out_of_bound_month_offset() {
        let today = Date::try_new_iso(2021, 1, 31).unwrap();
        // since 2021/02/31 isn't a valid date, `offset_date` auto-adjusts by constraining to the last day in February
        let today_plus_1_month = Date::try_new_iso(2021, 2, 28).unwrap();
        let offset = today
            .try_added_with_options(DateDuration::for_months(1), Default::default())
            .unwrap();
        assert_eq!(offset, today_plus_1_month);

        let today = Date::try_new_iso(2021, 1, 31).unwrap();
        // since 2021/02/31 isn't a valid date, `offset_date` auto-adjusts by constraining to the last day in February
        // and then adding the days
        let today_plus_1_month_1_day = Date::try_new_iso(2021, 3, 1).unwrap();
        let offset = today
            .try_added_with_options(
                DateDuration {
                    months: 1,
                    days: 1,
                    ..Default::default()
                },
                Default::default(),
            )
            .unwrap();
        assert_eq!(offset, today_plus_1_month_1_day);
    }

    #[test]
    fn test_iso_to_from_rd() {
        // Reminder: ISO year 0 is Gregorian year 1 BCE.
        // Year 0 is a leap year due to the 400-year rule.
        fn check(rd: i64, year: i32, month: u8, day: u8) {
            let rd = RataDie::new(rd);

            assert_eq!(
                Date::from_rata_die(rd, Iso),
                Date::try_new_iso(year, month, day).unwrap(),
                "RD: {rd:?}"
            );
        }
        check(-1828, -5, 12, 30);
        check(-1827, -5, 12, 31); // leap year
        check(-1826, -4, 1, 1);
        check(-1462, -4, 12, 30);
        check(-1461, -4, 12, 31);
        check(-1460, -3, 1, 1);
        check(-1459, -3, 1, 2);
        check(-732, -2, 12, 30);
        check(-731, -2, 12, 31);
        check(-730, -1, 1, 1);
        check(-367, -1, 12, 30);
        check(-366, -1, 12, 31);
        check(-365, 0, 1, 1); // leap year
        check(-364, 0, 1, 2);
        check(-1, 0, 12, 30);
        check(0, 0, 12, 31);
        check(1, 1, 1, 1);
        check(2, 1, 1, 2);
        check(364, 1, 12, 30);
        check(365, 1, 12, 31);
        check(366, 2, 1, 1);
        check(1459, 4, 12, 29);
        check(1460, 4, 12, 30);
        check(1461, 4, 12, 31); // leap year
        check(1462, 5, 1, 1);
    }
}