Coverage Report

Created: 2025-10-19 21:01

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