Coverage Report

Created: 2025-08-13 21:02

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/a220/proj/radnelac/src/calendar/iso.rs
Line
Count
Source
1
// This Source Code Form is subject to the terms of the Mozilla Public
2
// License, v. 2.0. If a copy of the MPL was not distributed with this
3
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5
use crate::calendar::gregorian::Gregorian;
6
use crate::calendar::prelude::CommonDate;
7
use crate::calendar::prelude::CommonWeekOfYear;
8
use crate::calendar::prelude::Quarter;
9
use crate::calendar::prelude::ToFromCommonDate;
10
use crate::calendar::prelude::ToFromOrdinalDate;
11
use crate::calendar::AllowYearZero;
12
use crate::calendar::CalendarMoment;
13
use crate::calendar::HasLeapYears;
14
use crate::clock::TimeOfDay;
15
use crate::common::math::TermNum;
16
use crate::day_count::BoundedDayCount;
17
use crate::day_count::CalculatedBounds;
18
use crate::day_count::Epoch;
19
use crate::day_count::Fixed;
20
use crate::day_count::FromFixed;
21
use crate::day_count::ToFixed;
22
use crate::day_cycle::Weekday;
23
use crate::CalendarError;
24
use num_traits::FromPrimitive;
25
use std::cmp::Ordering;
26
use std::num::NonZero;
27
28
/// Represents a date in the ISO-8601 week-date calendar
29
///
30
/// This is essentially an alternative naming system for Gregorian dates.
31
///
32
/// Despite being derived from the Gregorian calendar, **the ISO-8601 has a different year
33
/// start and year end than the Gregorian.** If the Gregorian year X ends in the middle of
34
/// the ISO week, the next days may be in Gregorian year X+1 and ISO year X.
35
///
36
/// ## Further reading
37
/// + [Wikipedia](https://en.wikipedia.org/wiki/ISO_week_date)
38
/// + [Rachel by the Bay](https://rachelbythebay.com/w/2018/04/20/iso/)
39
///   + describes the confusion of intermingling documentation for ISO and Gregorian dates
40
#[derive(Debug, PartialEq, Clone, Copy)]
41
pub struct ISO {
42
    year: i32,
43
    week: NonZero<u8>,
44
    day: Weekday,
45
}
46
47
impl ISO {
48
    /// Attempt to create a new ISO week date
49
23.8k
    pub fn try_new(year: i32, week: u8, day: Weekday) -> Result<Self, CalendarError> {
50
23.8k
        if week < 1 || week > 53 || (week == 53 && 
!Self::is_leap(year)66
) {
51
            //This if statement is structured specifically to minimize calls to Self::is_leap.
52
            //Self::is_leap calls Gregorian calendar functions which may exceed the effective
53
            //bounds.
54
0
            return Err(CalendarError::InvalidWeek);
55
23.8k
        }
56
23.8k
        Ok(ISO {
57
23.8k
            year: year,
58
23.8k
            week: NonZero::<u8>::new(week).expect("Checked in if"),
59
23.8k
            day: day,
60
23.8k
        })
61
23.8k
    }
62
63
304
    pub fn year(self) -> i32 {
64
304
        self.year
65
304
    }
66
67
6.40k
    pub fn week(self) -> NonZero<u8> {
68
6.40k
        self.week
69
6.40k
    }
70
71
    /// Note that the numeric values of the Weekday enum are not consistent with ISO-8601.
72
    /// Use day_num for the numeric day number.
73
256
    pub fn day(self) -> Weekday {
74
256
        self.day
75
256
    }
76
77
    /// Represents Sunday as 7 instead of 0, as required by ISO-8601.
78
0
    pub fn day_num(self) -> u8 {
79
0
        (self.day as u8).adjusted_remainder(7)
80
0
    }
81
82
15.9k
    pub fn new_year(year: i32) -> Self {
83
15.9k
        ISO::try_new(year, 1, Weekday::Monday).expect("Week 1 known to be valid")
84
15.9k
    }
85
}
86
87
impl PartialOrd for ISO {
88
2.05k
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
89
2.05k
        if self == other {
90
12
            Some(Ordering::Equal)
91
2.04k
        } else if self.year != other.year {
92
1.17k
            self.year.partial_cmp(&other.year)
93
865
        } else if self.week != other.week {
94
836
            self.week.partial_cmp(&other.week)
95
        } else {
96
29
            let self_day = (self.day as i64).adjusted_remainder(7);
97
29
            let other_day = (other.day as i64).adjusted_remainder(7);
98
29
            self_day.partial_cmp(&other_day)
99
        }
100
2.05k
    }
101
}
102
103
impl AllowYearZero for ISO {}
104
105
impl CalculatedBounds for ISO {}
106
107
impl Epoch for ISO {
108
0
    fn epoch() -> Fixed {
109
0
        Gregorian::epoch()
110
0
    }
111
}
112
113
impl HasLeapYears for ISO {
114
114
    fn is_leap(i_year: i32) -> bool {
115
        //LISTING 5.3 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
116
114
        let jan1 = Gregorian::try_year_start(i_year)
117
114
            .expect("Year known to be valid")
118
114
            .convert::<Weekday>();
119
114
        let dec31 = Gregorian::try_year_end(i_year)
120
114
            .expect("Year known to be valid")
121
114
            .convert::<Weekday>();
122
114
        jan1 == Weekday::Thursday || 
dec31 == Weekday::Thursday13
123
114
    }
124
}
125
126
impl FromFixed for ISO {
127
7.95k
    fn from_fixed(fixed_date: Fixed) -> ISO {
128
        //LISTING 5.2 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
129
7.95k
        let date = fixed_date.get_day_i();
130
7.95k
        let approx = Gregorian::ordinal_from_fixed(Fixed::cast_new(date - 3)).year;
131
7.95k
        let next = ISO::new_year(approx + 1).to_fixed().get_day_i();
132
7.95k
        let year = if date >= next { 
approx + 1170
} else {
approx7.78k
};
133
7.95k
        let current = ISO::new_year(year).to_fixed().get_day_i();
134
7.95k
        let week = (date - current).div_euclid(7) + 1;
135
7.95k
        debug_assert!(week < 55 && week > 0);
136
        //Calendrical Calculations stores "day" as 7 for Sunday, as per ISO.
137
        //However since we have an unambiguous enum, we can save such details for
138
        //functions that need it. We also adjust "to_fixed_unchecked"
139
7.95k
        let day = Weekday::from_u8(date.modulus(7) as u8).expect("In range due to modulus.");
140
7.95k
        ISO::try_new(year, week as u8, day).expect("Week known to be valid")
141
7.95k
    }
142
}
143
144
impl ToFixed for ISO {
145
16.1k
    fn to_fixed(self) -> Fixed {
146
        //LISTING 5.1 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
147
16.1k
        let g = CommonDate::new(self.year - 1, 12, 28);
148
16.1k
        let w = NonZero::<i16>::from(self.week);
149
        //Calendrical Calculations stores "day" as 7 for Sunday, as per ISO.
150
        //However since we have an unambiguous enum, we can save such details for
151
        //functions that need it. We also adjust "from_fixed_unchecked"
152
16.1k
        let day_i = (self.day as i64).adjusted_remainder(7);
153
16.1k
        let result = Gregorian::try_from_common_date(g)
154
16.1k
            .expect("month 12, day 28 is always valid for Gregorian")
155
16.1k
            .nth_kday(w, Weekday::Sunday)
156
16.1k
            .get_day_i()
157
16.1k
            + day_i;
158
16.1k
        Fixed::cast_new(result)
159
16.1k
    }
160
}
161
162
impl Quarter for ISO {
163
512
    fn quarter(self) -> NonZero<u8> {
164
512
        NonZero::new(((self.week().get() - 1) / 14) + 1).expect("(m - 1)/14 > -1")
165
512
    }
166
}
167
168
/// Represents a date *and time* in the ISO Calendar
169
pub type ISOMoment = CalendarMoment<ISO>;
170
171
impl ISOMoment {
172
0
    pub fn year(self) -> i32 {
173
0
        self.date().year()
174
0
    }
175
176
768
    pub fn week(self) -> NonZero<u8> {
177
768
        self.date().week()
178
768
    }
179
180
    /// Note that the numeric values of the Weekday enum are not consistent with ISO-8601.
181
    /// Use day_num for the numeric day number.
182
0
    pub fn day(self) -> Weekday {
183
0
        self.date().day()
184
0
    }
185
186
    /// Represents Sunday as 7 instead of 0, as required by ISO-8601.
187
0
    pub fn day_num(self) -> u8 {
188
0
        self.date().day_num()
189
0
    }
190
191
0
    pub fn new_year(year: i32) -> Self {
192
0
        ISOMoment::new(ISO::new_year(year), TimeOfDay::midnight())
193
0
    }
194
}
195
196
#[cfg(test)]
197
mod tests {
198
    use super::*;
199
    use crate::calendar::prelude::HasLeapYears;
200
    use crate::calendar::prelude::ToFromCommonDate;
201
    use crate::day_count::FIXED_MAX;
202
    use proptest::proptest;
203
    const MAX_YEARS: i32 = (FIXED_MAX / 365.25) as i32;
204
205
    #[test]
206
1
    fn week_of_impl() {
207
1
        let g = Gregorian::try_from_common_date(CommonDate::new(2025, 5, 15))
208
1
            .unwrap()
209
1
            .to_fixed();
210
1
        let i = ISO::from_fixed(g);
211
1
        assert_eq!(i.week().get(), 20);
212
1
    }
213
214
    #[test]
215
1
    fn epoch() {
216
1
        let i0 = ISO::from_fixed(Fixed::cast_new(0));
217
1
        let i1 = ISO::from_fixed(Fixed::cast_new(-1));
218
1
        assert!(i0 > i1, 
"i0: {:?}, i1: {:?}"0
, i0, i1);
219
1
    }
220
221
    proptest! {
222
        #[test]
223
        fn first_week(year in -MAX_YEARS..MAX_YEARS) {
224
            // https://en.wikipedia.org/wiki/ISO_week_date
225
            // > If 1 January is on a Monday, Tuesday, Wednesday or Thursday, it is in W01.
226
            // > If it is on a Friday, it is part of W53 of the previous year. If it is on a
227
            // > Saturday, it is part of the last week of the previous year which is numbered
228
            // > W52 in a common year and W53 in a leap year. If it is on a Sunday, it is part
229
            // > of W52 of the previous year.
230
            let g = Gregorian::try_from_common_date(CommonDate {
231
                year,
232
                month: 1,
233
                day: 1,
234
            }).unwrap();
235
            let f = g.to_fixed();
236
            let w = Weekday::from_fixed(f);
237
            let i = ISO::from_fixed(f);
238
            let expected_week: u8 = match w {
239
                Weekday::Monday => 1,
240
                Weekday::Tuesday => 1,
241
                Weekday::Wednesday => 1,
242
                Weekday::Thursday => 1,
243
                Weekday::Friday => 53,
244
                Weekday::Saturday => if Gregorian::is_leap(year - 1) {53} else {52},
245
                Weekday::Sunday => 52,
246
            };
247
            let expected_year: i32 = if expected_week == 1 { year } else { year - 1 };
248
            assert_eq!(i.day(), w);
249
            assert_eq!(i.week().get(), expected_week);
250
            assert_eq!(i.year(), expected_year);
251
            if expected_week == 53 {
252
                assert!(ISO::is_leap(i.year()));
253
            }
254
        }
255
256
        #[test]
257
        fn fixed_week_numbers(y1 in -MAX_YEARS..MAX_YEARS, y2 in -MAX_YEARS..MAX_YEARS) {
258
            // https://en.wikipedia.org/wiki/ISO_week_date
259
            // > For all years, 8 days have a fixed ISO week number
260
            // > (between W01 and W08) in January and February
261
            // Month       Days                Weeks
262
            // January     04  11  18  25      W01 – W04
263
            // February    01  08  15  22  29  W05 – W09
264
            let targets = [
265
                (1, 4), (1, 11), (1, 18), (1, 25),
266
                (2, 1), (2, 8), (2, 15), (2, 22),
267
            ];
268
            for target in targets {
269
                let g1 = Gregorian::try_from_common_date(CommonDate {
270
                    year: y1,
271
                    month: target.0,
272
                    day: target.1,
273
                }).unwrap();
274
                let g2 = Gregorian::try_from_common_date(CommonDate {
275
                    year: y2,
276
                    month: target.0,
277
                    day: target.1,
278
                }).unwrap();
279
                assert_eq!(g1.convert::<ISO>().week(), g2.convert::<ISO>().week());
280
            }
281
        }
282
    }
283
}