Skip to main content

tzcraft/
units.rs

1//! Calendar units and the ISO week date, in the `chrono`-compatible shape.
2//!
3//! [`Days`] and [`Months`] are non-negative, type-safe calendar offsets.
4//! Wrapping them in distinct types means a day count can never be silently
5//! mistaken for a month count — the same class of unit confusion `chrono`
6//! eliminates with its `Days` / `Months` newtypes. [`IsoWeek`] is the ISO
7//! 8601 week date `(iso_year, week)`.
8
9use core::fmt;
10
11use crate::calendar::{days_from_civil, iso_week_from_civil, weekday_from_civil, Weekday};
12use crate::date::Date;
13use crate::error::{Error, Result};
14
15/// A non-negative number of calendar days.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct Days(pub u64);
18
19impl Days {
20    /// Build from a day count.
21    pub const fn new(days: u64) -> Days {
22        Days(days)
23    }
24
25    /// The inner day count.
26    pub const fn get(self) -> u64 {
27        self.0
28    }
29}
30
31/// A non-negative number of calendar months.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct Months(pub u32);
34
35impl Months {
36    /// Build from a month count.
37    pub const fn new(months: u32) -> Months {
38        Months(months)
39    }
40
41    /// The inner month count.
42    pub const fn get(self) -> u32 {
43        self.0
44    }
45}
46
47/// An ISO 8601 week date: an ISO year and a week number in `1..=53`.
48///
49/// The ISO year can differ from the calendar year for the first days of
50/// January and the last days of December (e.g. `2021-01-01` is ISO week
51/// `2020-W53`).
52#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
53pub struct IsoWeek {
54    year: i32,
55    week: u32,
56}
57
58impl IsoWeek {
59    pub(crate) const fn new(year: i32, week: u32) -> IsoWeek {
60        IsoWeek { year, week }
61    }
62
63    /// The ISO year.
64    pub const fn year(self) -> i32 {
65        self.year
66    }
67
68    /// The ISO week number (1-based).
69    pub const fn week(self) -> u32 {
70        self.week
71    }
72
73    /// `(iso_year, week)`.
74    pub const fn parts(self) -> (i32, u32) {
75        (self.year, self.week)
76    }
77
78    /// The Monday that starts this week, as a calendar date.
79    ///
80    /// The resulting date lives in the ISO year (which may differ from the
81    /// calendar year at the boundaries).
82    pub fn monday(self) -> Result<Date> {
83        let max_week = iso_week_from_civil(self.year, 12, 28).1;
84        if self.week == 0 || self.week > max_week {
85            return Err(Error::out_of_range("iso week"));
86        }
87        // Week 1 of the ISO year is the week containing January 4.
88        let jan4 = days_from_civil(self.year, 1, 4);
89        let monday_week1 = jan4 - weekday_from_civil(jan4) as i64;
90        Date::from_days_checked(monday_week1 + (self.week as i64 - 1) * 7)
91    }
92
93    /// The weekday of the Monday of this week (always the week's first day).
94    pub const fn first_weekday(self) -> Weekday {
95        Weekday::Monday
96    }
97}
98
99impl fmt::Display for IsoWeek {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(f, "{}-W{:02}", self.year, self.week)
102    }
103}
104
105#[cfg(all(test, feature = "alloc"))]
106mod tests {
107    use super::*;
108    use alloc::string::ToString;
109
110    #[test]
111    fn iso_week_monday() {
112        let w = IsoWeek::new(2021, 1);
113        assert_eq!(w.monday().unwrap(), Date::from_ymd(2021, 1, 4).unwrap());
114        let w = IsoWeek::new(2020, 53);
115        assert_eq!(w.monday().unwrap(), Date::from_ymd(2020, 12, 28).unwrap());
116        let w = IsoWeek::new(2026, 1);
117        assert_eq!(w.monday().unwrap(), Date::from_ymd(2025, 12, 29).unwrap());
118        assert_eq!(w.to_string(), "2026-W01");
119        assert!(IsoWeek::new(2024, 54).monday().is_err());
120        assert!(IsoWeek::new(2021, 53).monday().is_err());
121    }
122}