jiff/civil/
iso_week_date.rs

1use crate::{
2    civil::{Date, DateTime, Weekday},
3    error::{err, Error},
4    util::{
5        rangeint::RInto,
6        t::{self, ISOWeek, ISOYear, C},
7    },
8    Zoned,
9};
10
11/// A type representing an [ISO 8601 week date].
12///
13/// The ISO 8601 week date scheme devises a calendar where days are identified
14/// by their year, week number and weekday. All years have either precisely
15/// 52 or 53 weeks.
16///
17/// The first week of an ISO 8601 year corresponds to the week containing the
18/// first Thursday of the year. For this reason, an ISO 8601 week year can be
19/// mismatched with the day's corresponding Gregorian year. For example, the
20/// ISO 8601 week date for `1995-01-01` is `1994-W52-7` (with `7` corresponding
21/// to Sunday).
22///
23/// ISO 8601 also considers Monday to be the start of the week, and uses
24/// a 1-based numbering system. That is, Monday corresponds to `1` while
25/// Sunday corresponds to `7` and is the last day of the week. Weekdays are
26/// encapsulated by the [`Weekday`] type, which provides routines for easily
27/// converting between different schemes (such as weeks where Sunday is the
28/// beginning).
29///
30/// [ISO 8601 week date]: https://en.wikipedia.org/wiki/ISO_week_date
31///
32/// # Use case
33///
34/// Some domains use this method of timekeeping. Otherwise, unless you
35/// specifically want a week oriented calendar, it's likely that you'll never
36/// need to care about this type.
37///
38/// # Default value
39///
40/// For convenience, this type implements the `Default` trait. Its default
41/// value is the first day of the zeroth year. i.e., `0000-W1-1`.
42///
43/// # Example: sample dates
44///
45/// This example shows a couple ISO 8601 week dates and their corresponding
46/// Gregorian equivalents:
47///
48/// ```
49/// use jiff::civil::{ISOWeekDate, Weekday, date};
50///
51/// let d = date(2019, 12, 30);
52/// let weekdate = ISOWeekDate::new(2020, 1, Weekday::Monday).unwrap();
53/// assert_eq!(d.iso_week_date(), weekdate);
54///
55/// let d = date(2024, 3, 9);
56/// let weekdate = ISOWeekDate::new(2024, 10, Weekday::Saturday).unwrap();
57/// assert_eq!(d.iso_week_date(), weekdate);
58/// ```
59///
60/// # Example: overlapping leap and long years
61///
62/// A "long" ISO 8601 week year is a year with 53 weeks. That is, it is a year
63/// that includes a leap week. This example shows all years in the 20th
64/// century that are both Gregorian leap years and long years.
65///
66/// ```
67/// use jiff::civil::date;
68///
69/// let mut overlapping = vec![];
70/// for year in 1900..=1999 {
71///     let date = date(year, 1, 1);
72///     if date.in_leap_year() && date.iso_week_date().in_long_year() {
73///         overlapping.push(year);
74///     }
75/// }
76/// assert_eq!(overlapping, vec![
77///     1904, 1908, 1920, 1932, 1936, 1948, 1960, 1964, 1976, 1988, 1992,
78/// ]);
79/// ```
80///
81/// # Example: printing all weeks in a year
82///
83/// The ISO 8601 week calendar can be useful when you want to categorize
84/// things into buckets of weeks where all weeks are exactly 7 days, _and_
85/// you don't care as much about the precise Gregorian year. Here's an example
86/// that prints all of the ISO 8601 weeks in one ISO 8601 week year:
87///
88/// ```
89/// use jiff::{civil::{ISOWeekDate, Weekday}, ToSpan};
90///
91/// let target_year = 2024;
92/// let iso_week_date = ISOWeekDate::new(target_year, 1, Weekday::Monday)?;
93/// // Create a series of dates via the Gregorian calendar. But since a
94/// // Gregorian week and an ISO 8601 week calendar week are both 7 days,
95/// // this works fine.
96/// let weeks = iso_week_date
97///     .date()
98///     .series(1.week())
99///     .map(|d| d.iso_week_date())
100///     .take_while(|wd| wd.year() == target_year);
101/// for start_of_week in weeks {
102///     let end_of_week = start_of_week.last_of_week()?;
103///     println!(
104///         "ISO week {}: {} - {}",
105///         start_of_week.week(),
106///         start_of_week.date(),
107///         end_of_week.date()
108///     );
109/// }
110/// # Ok::<(), Box<dyn std::error::Error>>(())
111/// ```
112#[derive(Clone, Copy, Hash)]
113pub struct ISOWeekDate {
114    year: ISOYear,
115    week: ISOWeek,
116    weekday: Weekday,
117}
118
119impl ISOWeekDate {
120    /// The maximum representable ISO week date.
121    ///
122    /// The maximum corresponds to the ISO week date of the maximum [`Date`]
123    /// value. That is, `-9999-01-01`.
124    pub const MIN: ISOWeekDate = ISOWeekDate {
125        year: ISOYear::new_unchecked(-9999),
126        week: ISOWeek::new_unchecked(1),
127        weekday: Weekday::Monday,
128    };
129
130    /// The minimum representable ISO week date.
131    ///
132    /// The minimum corresponds to the ISO week date of the minimum [`Date`]
133    /// value. That is, `9999-12-31`.
134    pub const MAX: ISOWeekDate = ISOWeekDate {
135        year: ISOYear::new_unchecked(9999),
136        week: ISOWeek::new_unchecked(52),
137        weekday: Weekday::Friday,
138    };
139
140    /// The first day of the zeroth year.
141    ///
142    /// This is guaranteed to be equivalent to `ISOWeekDate::default()`. Note
143    /// that this is not equivalent to `Date::default()`.
144    ///
145    /// # Example
146    ///
147    /// ```
148    /// use jiff::civil::{ISOWeekDate, date};
149    ///
150    /// assert_eq!(ISOWeekDate::ZERO, ISOWeekDate::default());
151    /// // The first day of the 0th year in the ISO week calendar is actually
152    /// // the third day of the 0th year in the proleptic Gregorian calendar!
153    /// assert_eq!(ISOWeekDate::default().date(), date(0, 1, 3));
154    /// ```
155    pub const ZERO: ISOWeekDate = ISOWeekDate {
156        year: ISOYear::new_unchecked(0),
157        week: ISOWeek::new_unchecked(1),
158        weekday: Weekday::Monday,
159    };
160
161    /// Create a new ISO week date from it constituent parts.
162    ///
163    /// If the given values are out of range (based on what is representable
164    /// as a [`Date`]), then this returns an error. This will also return an
165    /// error if a leap week is given (week number `53`) for a year that does
166    /// not contain a leap week.
167    ///
168    /// # Example
169    ///
170    /// This example shows some the boundary conditions involving minimum
171    /// and maximum dates:
172    ///
173    /// ```
174    /// use jiff::civil::{ISOWeekDate, Weekday, date};
175    ///
176    /// // The year 1949 does not contain a leap week.
177    /// assert!(ISOWeekDate::new(1949, 53, Weekday::Monday).is_err());
178    ///
179    /// // Examples of dates at or exceeding the maximum.
180    /// let max = ISOWeekDate::new(9999, 52, Weekday::Friday).unwrap();
181    /// assert_eq!(max, ISOWeekDate::MAX);
182    /// assert_eq!(max.date(), date(9999, 12, 31));
183    /// assert!(ISOWeekDate::new(9999, 52, Weekday::Saturday).is_err());
184    /// assert!(ISOWeekDate::new(9999, 53, Weekday::Monday).is_err());
185    ///
186    /// // Examples of dates at or exceeding the minimum.
187    /// let min = ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap();
188    /// assert_eq!(min, ISOWeekDate::MIN);
189    /// assert_eq!(min.date(), date(-9999, 1, 1));
190    /// assert!(ISOWeekDate::new(-10000, 52, Weekday::Sunday).is_err());
191    /// ```
192    #[inline]
193    pub fn new(
194        year: i16,
195        week: i8,
196        weekday: Weekday,
197    ) -> Result<ISOWeekDate, Error> {
198        let year = ISOYear::try_new("year", year)?;
199        let week = ISOWeek::try_new("week", week)?;
200        ISOWeekDate::new_ranged(year, week, weekday)
201    }
202
203    /// Converts a Gregorian date to an ISO week date.
204    ///
205    /// The minimum and maximum allowed values of an ISO week date are
206    /// set based on the minimum and maximum values of a `Date`. Therefore,
207    /// converting to and from `Date` values is non-lossy and infallible.
208    ///
209    /// This routine is equivalent to [`Date::iso_week_date`]. This routine
210    /// is also available via a `From<Date>` trait implementation for
211    /// `ISOWeekDate`.
212    ///
213    /// # Example
214    ///
215    /// ```
216    /// use jiff::civil::{ISOWeekDate, Weekday, date};
217    ///
218    /// let weekdate = ISOWeekDate::from_date(date(1948, 2, 10));
219    /// assert_eq!(
220    ///     weekdate,
221    ///     ISOWeekDate::new(1948, 7, Weekday::Tuesday).unwrap(),
222    /// );
223    /// ```
224    #[inline]
225    pub fn from_date(date: Date) -> ISOWeekDate {
226        date.iso_week_date()
227    }
228
229    // N.B. I tried defining a `ISOWeekDate::constant` for defining ISO week
230    // dates as constants, but it was too annoying to do. We could do it if
231    // there was a compelling reason for it though.
232
233    /// Returns the year component of this ISO 8601 week date.
234    ///
235    /// The value returned is guaranteed to be in the range `-9999..=9999`.
236    ///
237    /// # Example
238    ///
239    /// ```
240    /// use jiff::civil::date;
241    ///
242    /// let weekdate = date(2019, 12, 30).iso_week_date();
243    /// assert_eq!(weekdate.year(), 2020);
244    /// ```
245    #[inline]
246    pub fn year(self) -> i16 {
247        self.year_ranged().get()
248    }
249
250    /// Returns the week component of this ISO 8601 week date.
251    ///
252    /// The value returned is guaranteed to be in the range `1..=53`. A
253    /// value of `53` can only occur for "long" years. That is, years
254    /// with a leap week. This occurs precisely in cases for which
255    /// [`ISOWeekDate::in_long_year`] returns `true`.
256    ///
257    /// # Example
258    ///
259    /// ```
260    /// use jiff::civil::date;
261    ///
262    /// let weekdate = date(2019, 12, 30).iso_week_date();
263    /// assert_eq!(weekdate.year(), 2020);
264    /// assert_eq!(weekdate.week(), 1);
265    ///
266    /// let weekdate = date(1948, 12, 31).iso_week_date();
267    /// assert_eq!(weekdate.year(), 1948);
268    /// assert_eq!(weekdate.week(), 53);
269    /// ```
270    #[inline]
271    pub fn week(self) -> i8 {
272        self.week_ranged().get()
273    }
274
275    /// Returns the day component of this ISO 8601 week date.
276    ///
277    /// One can use methods on `Weekday` such as
278    /// [`Weekday::to_monday_one_offset`]
279    /// and
280    /// [`Weekday::to_sunday_zero_offset`]
281    /// to convert the weekday to a number.
282    ///
283    /// # Example
284    ///
285    /// ```
286    /// use jiff::civil::{date, Weekday};
287    ///
288    /// let weekdate = date(1948, 12, 31).iso_week_date();
289    /// assert_eq!(weekdate.year(), 1948);
290    /// assert_eq!(weekdate.week(), 53);
291    /// assert_eq!(weekdate.weekday(), Weekday::Friday);
292    /// assert_eq!(weekdate.weekday().to_monday_zero_offset(), 4);
293    /// assert_eq!(weekdate.weekday().to_monday_one_offset(), 5);
294    /// assert_eq!(weekdate.weekday().to_sunday_zero_offset(), 5);
295    /// assert_eq!(weekdate.weekday().to_sunday_one_offset(), 6);
296    /// ```
297    #[inline]
298    pub fn weekday(self) -> Weekday {
299        self.weekday
300    }
301
302    /// Returns the ISO 8601 week date corresponding to the first day in the
303    /// week of this week date. The date returned is guaranteed to have a
304    /// weekday of [`Weekday::Monday`].
305    ///
306    /// # Errors
307    ///
308    /// Since `-9999-01-01` falls on a Monday, it follows that the minimum
309    /// support Gregorian date is exactly equivalent to the minimum supported
310    /// ISO 8601 week date. This means that this routine can never actually
311    /// fail, but only insomuch as the minimums line up. For that reason, and
312    /// for consistency with [`ISOWeekDate::last_of_week`], the API is
313    /// fallible.
314    ///
315    /// # Example
316    ///
317    /// ```
318    /// use jiff::civil::{ISOWeekDate, Weekday, date};
319    ///
320    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
321    /// assert_eq!(wd.date(), date(2025, 1, 29));
322    /// assert_eq!(
323    ///     wd.first_of_week()?,
324    ///     ISOWeekDate::new(2025, 5, Weekday::Monday).unwrap(),
325    /// );
326    ///
327    /// // Works even for the minimum date.
328    /// assert_eq!(
329    ///     ISOWeekDate::MIN.first_of_week()?,
330    ///     ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap(),
331    /// );
332    ///
333    /// # Ok::<(), Box<dyn std::error::Error>>(())
334    /// ```
335    #[inline]
336    pub fn first_of_week(self) -> Result<ISOWeekDate, Error> {
337        // I believe this can never return an error because `Monday` is in
338        // bounds for all possible year-and-week combinations. This is *only*
339        // because -9999-01-01 corresponds to -9999-W01-Monday. Which is kinda
340        // lucky. And I guess if we ever change the ranges, this could become
341        // fallible.
342        ISOWeekDate::new_ranged(
343            self.year_ranged(),
344            self.week_ranged(),
345            Weekday::Monday,
346        )
347    }
348
349    /// Returns the ISO 8601 week date corresponding to the last day in the
350    /// week of this week date. The date returned is guaranteed to have a
351    /// weekday of [`Weekday::Sunday`].
352    ///
353    /// # Errors
354    ///
355    /// This can return an error if the last day of the week exceeds Jiff's
356    /// maximum Gregorian date of `9999-12-31`. It turns out this can happen
357    /// since `9999-12-31` falls on a Friday.
358    ///
359    /// # Example
360    ///
361    /// ```
362    /// use jiff::civil::{ISOWeekDate, Weekday, date};
363    ///
364    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
365    /// assert_eq!(wd.date(), date(2025, 1, 29));
366    /// assert_eq!(
367    ///     wd.last_of_week()?,
368    ///     ISOWeekDate::new(2025, 5, Weekday::Sunday).unwrap(),
369    /// );
370    ///
371    /// // Unlike `first_of_week`, this routine can actually fail on real
372    /// // values, although, only when close to the maximum supported date.
373    /// assert_eq!(
374    ///     ISOWeekDate::MAX.last_of_week().unwrap_err().to_string(),
375    ///     "parameter 'weekday' with value 7 is not \
376    ///      in the required range of 1..=5",
377    /// );
378    ///
379    /// # Ok::<(), Box<dyn std::error::Error>>(())
380    /// ```
381    #[inline]
382    pub fn last_of_week(self) -> Result<ISOWeekDate, Error> {
383        // This can return an error when in the last week of the maximum year
384        // supported by Jiff. That's because the Saturday and Sunday of that
385        // week are actually in Gregorian year 10,000.
386        ISOWeekDate::new_ranged(
387            self.year_ranged(),
388            self.week_ranged(),
389            Weekday::Sunday,
390        )
391    }
392
393    /// Returns the ISO 8601 week date corresponding to the first day in the
394    /// year of this week date. The date returned is guaranteed to have a
395    /// weekday of [`Weekday::Monday`].
396    ///
397    /// # Errors
398    ///
399    /// Since `-9999-01-01` falls on a Monday, it follows that the minimum
400    /// support Gregorian date is exactly equivalent to the minimum supported
401    /// ISO 8601 week date. This means that this routine can never actually
402    /// fail, but only insomuch as the minimums line up. For that reason, and
403    /// for consistency with [`ISOWeekDate::last_of_year`], the API is
404    /// fallible.
405    ///
406    /// # Example
407    ///
408    /// ```
409    /// use jiff::civil::{ISOWeekDate, Weekday, date};
410    ///
411    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
412    /// assert_eq!(wd.date(), date(2025, 1, 29));
413    /// assert_eq!(
414    ///     wd.first_of_year()?,
415    ///     ISOWeekDate::new(2025, 1, Weekday::Monday).unwrap(),
416    /// );
417    ///
418    /// // Works even for the minimum date.
419    /// assert_eq!(
420    ///     ISOWeekDate::MIN.first_of_year()?,
421    ///     ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap(),
422    /// );
423    ///
424    /// # Ok::<(), Box<dyn std::error::Error>>(())
425    /// ```
426    #[inline]
427    pub fn first_of_year(self) -> Result<ISOWeekDate, Error> {
428        // I believe this can never return an error because `Monday` is in
429        // bounds for all possible years. This is *only* because -9999-01-01
430        // corresponds to -9999-W01-Monday. Which is kinda lucky. And I guess
431        // if we ever change the ranges, this could become fallible.
432        ISOWeekDate::new_ranged(self.year_ranged(), C(1), Weekday::Monday)
433    }
434
435    /// Returns the ISO 8601 week date corresponding to the last day in the
436    /// year of this week date. The date returned is guaranteed to have a
437    /// weekday of [`Weekday::Sunday`].
438    ///
439    /// # Errors
440    ///
441    /// This can return an error if the last day of the year exceeds Jiff's
442    /// maximum Gregorian date of `9999-12-31`. It turns out this can happen
443    /// since `9999-12-31` falls on a Friday.
444    ///
445    /// # Example
446    ///
447    /// ```
448    /// use jiff::civil::{ISOWeekDate, Weekday, date};
449    ///
450    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
451    /// assert_eq!(wd.date(), date(2025, 1, 29));
452    /// assert_eq!(
453    ///     wd.last_of_year()?,
454    ///     ISOWeekDate::new(2025, 52, Weekday::Sunday).unwrap(),
455    /// );
456    ///
457    /// // Works correctly for "long" years.
458    /// let wd = ISOWeekDate::new(2026, 5, Weekday::Wednesday).unwrap();
459    /// assert_eq!(wd.date(), date(2026, 1, 28));
460    /// assert_eq!(
461    ///     wd.last_of_year()?,
462    ///     ISOWeekDate::new(2026, 53, Weekday::Sunday).unwrap(),
463    /// );
464    ///
465    /// // Unlike `first_of_year`, this routine can actually fail on real
466    /// // values, although, only when close to the maximum supported date.
467    /// assert_eq!(
468    ///     ISOWeekDate::MAX.last_of_year().unwrap_err().to_string(),
469    ///     "parameter 'weekday' with value 7 is not \
470    ///      in the required range of 1..=5",
471    /// );
472    ///
473    /// # Ok::<(), Box<dyn std::error::Error>>(())
474    /// ```
475    #[inline]
476    pub fn last_of_year(self) -> Result<ISOWeekDate, Error> {
477        // This can return an error when in the maximum year supported by
478        // Jiff. That's because the last Saturday and Sunday of that year are
479        // actually in Gregorian year 10,000.
480        let week = if self.in_long_year() {
481            ISOWeek::V::<53, 52, 53>()
482        } else {
483            ISOWeek::V::<52, 52, 53>()
484        };
485        ISOWeekDate::new_ranged(self.year_ranged(), week, Weekday::Sunday)
486    }
487
488    /// Returns the total number of days in the year of this ISO 8601 week
489    /// date.
490    ///
491    /// It is guaranteed that the value returned is either 364 or 371. The
492    /// latter case occurs precisely when [`ISOWeekDate::in_long_year`]
493    /// returns `true`.
494    ///
495    /// # Example
496    ///
497    /// ```
498    /// use jiff::civil::{ISOWeekDate, Weekday};
499    ///
500    /// let weekdate = ISOWeekDate::new(2025, 7, Weekday::Monday).unwrap();
501    /// assert_eq!(weekdate.days_in_year(), 364);
502    /// let weekdate = ISOWeekDate::new(2026, 7, Weekday::Monday).unwrap();
503    /// assert_eq!(weekdate.days_in_year(), 371);
504    /// ```
505    #[inline]
506    pub fn days_in_year(self) -> i16 {
507        if self.in_long_year() {
508            371
509        } else {
510            364
511        }
512    }
513
514    /// Returns the total number of weeks in the year of this ISO 8601 week
515    /// date.
516    ///
517    /// It is guaranteed that the value returned is either 52 or 53. The
518    /// latter case occurs precisely when [`ISOWeekDate::in_long_year`]
519    /// returns `true`.
520    ///
521    /// # Example
522    ///
523    /// ```
524    /// use jiff::civil::{ISOWeekDate, Weekday};
525    ///
526    /// let weekdate = ISOWeekDate::new(2025, 7, Weekday::Monday).unwrap();
527    /// assert_eq!(weekdate.weeks_in_year(), 52);
528    /// let weekdate = ISOWeekDate::new(2026, 7, Weekday::Monday).unwrap();
529    /// assert_eq!(weekdate.weeks_in_year(), 53);
530    /// ```
531    #[inline]
532    pub fn weeks_in_year(self) -> i8 {
533        if self.in_long_year() {
534            53
535        } else {
536            52
537        }
538    }
539
540    /// Returns true if and only if the year of this week date is a "long"
541    /// year.
542    ///
543    /// A long year is one that contains precisely 53 weeks. All other years
544    /// contain precisely 52 weeks.
545    ///
546    /// # Example
547    ///
548    /// ```
549    /// use jiff::civil::{ISOWeekDate, Weekday};
550    ///
551    /// let weekdate = ISOWeekDate::new(1948, 7, Weekday::Monday).unwrap();
552    /// assert!(weekdate.in_long_year());
553    /// let weekdate = ISOWeekDate::new(1949, 7, Weekday::Monday).unwrap();
554    /// assert!(!weekdate.in_long_year());
555    /// ```
556    #[inline]
557    pub fn in_long_year(self) -> bool {
558        is_long_year(self.year_ranged())
559    }
560
561    /// Returns the ISO 8601 date immediately following this one.
562    ///
563    /// # Errors
564    ///
565    /// This returns an error when this date is the maximum value.
566    ///
567    /// # Example
568    ///
569    /// ```
570    /// use jiff::civil::{ISOWeekDate, Weekday};
571    ///
572    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
573    /// assert_eq!(
574    ///     wd.tomorrow()?,
575    ///     ISOWeekDate::new(2025, 5, Weekday::Thursday).unwrap(),
576    /// );
577    ///
578    /// // The max doesn't have a tomorrow.
579    /// assert!(ISOWeekDate::MAX.tomorrow().is_err());
580    ///
581    /// # Ok::<(), Box<dyn std::error::Error>>(())
582    /// ```
583    #[inline]
584    pub fn tomorrow(self) -> Result<ISOWeekDate, Error> {
585        // I suppose we could probably implement this in a more efficient
586        // manner but avoiding the roundtrip through Gregorian dates.
587        self.date().tomorrow().map(|d| d.iso_week_date())
588    }
589
590    /// Returns the ISO 8601 week date immediately preceding this one.
591    ///
592    /// # Errors
593    ///
594    /// This returns an error when this date is the minimum value.
595    ///
596    /// # Example
597    ///
598    /// ```
599    /// use jiff::civil::{ISOWeekDate, Weekday};
600    ///
601    /// let wd = ISOWeekDate::new(2025, 5, Weekday::Wednesday).unwrap();
602    /// assert_eq!(
603    ///     wd.yesterday()?,
604    ///     ISOWeekDate::new(2025, 5, Weekday::Tuesday).unwrap(),
605    /// );
606    ///
607    /// // The min doesn't have a yesterday.
608    /// assert!(ISOWeekDate::MIN.yesterday().is_err());
609    ///
610    /// # Ok::<(), Box<dyn std::error::Error>>(())
611    /// ```
612    #[inline]
613    pub fn yesterday(self) -> Result<ISOWeekDate, Error> {
614        // I suppose we could probably implement this in a more efficient
615        // manner but avoiding the roundtrip through Gregorian dates.
616        self.date().yesterday().map(|d| d.iso_week_date())
617    }
618
619    /// Converts this ISO week date to a Gregorian [`Date`].
620    ///
621    /// The minimum and maximum allowed values of an ISO week date are
622    /// set based on the minimum and maximum values of a `Date`. Therefore,
623    /// converting to and from `Date` values is non-lossy and infallible.
624    ///
625    /// This routine is equivalent to [`Date::from_iso_week_date`].
626    ///
627    /// # Example
628    ///
629    /// ```
630    /// use jiff::civil::{ISOWeekDate, Weekday, date};
631    ///
632    /// let weekdate = ISOWeekDate::new(1948, 7, Weekday::Tuesday).unwrap();
633    /// assert_eq!(weekdate.date(), date(1948, 2, 10));
634    /// ```
635    #[inline]
636    pub fn date(self) -> Date {
637        Date::from_iso_week_date(self)
638    }
639}
640
641impl ISOWeekDate {
642    /// Creates a new ISO week date from ranged values.
643    ///
644    /// While the ranged values given eliminate some error cases, not all
645    /// combinations of year/week/weekday values are valid ISO week dates
646    /// supported by this crate. For example, a week of `53` for short years,
647    /// or more niche, a week date that would be bigger than what is supported
648    /// by our `Date` type.
649    #[inline]
650    pub(crate) fn new_ranged(
651        year: impl RInto<ISOYear>,
652        week: impl RInto<ISOWeek>,
653        weekday: Weekday,
654    ) -> Result<ISOWeekDate, Error> {
655        let year = year.rinto();
656        let week = week.rinto();
657        // All combinations of years, weeks and weekdays allowed by our
658        // range types are valid ISO week dates with one exception: a week
659        // number of 53 is only valid for "long" years. Or years with an ISO
660        // leap week. It turns out this only happens when the last day of the
661        // year is a Thursday.
662        //
663        // Note that if the ranges in this crate are changed, this could be
664        // a little trickier if the range of ISOYear is different from Year.
665        debug_assert_eq!(t::Year::MIN, ISOYear::MIN);
666        debug_assert_eq!(t::Year::MAX, ISOYear::MAX);
667        if week == C(53) && !is_long_year(year) {
668            return Err(err!(
669                "ISO week number `{week}` is invalid for year `{year}`"
670            ));
671        }
672        // And also, the maximum Date constrains what we can utter with
673        // ISOWeekDate so that we can preserve infallible conversions between
674        // them. So since 9999-12-31 maps to 9999 W52 Friday, it follows that
675        // Saturday and Sunday are not allowed. So reject them.
676        //
677        // We don't need to worry about the minimum because the minimum date
678        // (-9999-01-01) corresponds also to the minimum possible combination
679        // of an ISO week date's fields: -9999 W01 Monday. Nice.
680        if year == ISOYear::MAX_SELF
681            && week == C(52)
682            && weekday.to_monday_zero_offset()
683                > Weekday::Friday.to_monday_zero_offset()
684        {
685            return Err(Error::range(
686                "weekday",
687                weekday.to_monday_one_offset(),
688                Weekday::Monday.to_monday_one_offset(),
689                Weekday::Friday.to_monday_one_offset(),
690            ));
691        }
692        Ok(ISOWeekDate { year, week, weekday })
693    }
694
695    /// Like `ISOWeekDate::new_ranged`, but constrains out-of-bounds values
696    /// to their closest valid equivalent.
697    ///
698    /// For example, given 9999 W52 Saturday, this will return 9999 W52 Friday.
699    #[cfg(test)]
700    #[inline]
701    pub(crate) fn new_ranged_constrain(
702        year: impl RInto<ISOYear>,
703        week: impl RInto<ISOWeek>,
704        mut weekday: Weekday,
705    ) -> ISOWeekDate {
706        let year = year.rinto();
707        let mut week = week.rinto();
708        debug_assert_eq!(t::Year::MIN, ISOYear::MIN);
709        debug_assert_eq!(t::Year::MAX, ISOYear::MAX);
710        if week == C(53) && !is_long_year(year) {
711            week = ISOWeek::new(52).unwrap();
712        }
713        if year == ISOYear::MAX_SELF
714            && week == C(52)
715            && weekday.to_monday_zero_offset()
716                > Weekday::Friday.to_monday_zero_offset()
717        {
718            weekday = Weekday::Friday;
719        }
720        ISOWeekDate { year, week, weekday }
721    }
722
723    #[inline]
724    pub(crate) fn year_ranged(self) -> ISOYear {
725        self.year
726    }
727
728    #[inline]
729    pub(crate) fn week_ranged(self) -> ISOWeek {
730        self.week
731    }
732}
733
734impl Default for ISOWeekDate {
735    fn default() -> ISOWeekDate {
736        ISOWeekDate::ZERO
737    }
738}
739
740impl core::fmt::Debug for ISOWeekDate {
741    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
742        f.debug_struct("ISOWeekDate")
743            .field("year", &self.year_ranged().debug())
744            .field("week", &self.week_ranged().debug())
745            .field("weekday", &self.weekday)
746            .finish()
747    }
748}
749
750impl Eq for ISOWeekDate {}
751
752impl PartialEq for ISOWeekDate {
753    #[inline]
754    fn eq(&self, other: &ISOWeekDate) -> bool {
755        // We roll our own so that we can call 'get' on our ranged integers
756        // in order to provoke panics for bugs in dealing with boundary
757        // conditions.
758        self.weekday == other.weekday
759            && self.week.get() == other.week.get()
760            && self.year.get() == other.year.get()
761    }
762}
763
764impl Ord for ISOWeekDate {
765    #[inline]
766    fn cmp(&self, other: &ISOWeekDate) -> core::cmp::Ordering {
767        (self.year.get(), self.week.get(), self.weekday.to_monday_one_offset())
768            .cmp(&(
769                other.year.get(),
770                other.week.get(),
771                other.weekday.to_monday_one_offset(),
772            ))
773    }
774}
775
776impl PartialOrd for ISOWeekDate {
777    #[inline]
778    fn partial_cmp(&self, other: &ISOWeekDate) -> Option<core::cmp::Ordering> {
779        Some(self.cmp(other))
780    }
781}
782
783impl From<Date> for ISOWeekDate {
784    #[inline]
785    fn from(date: Date) -> ISOWeekDate {
786        ISOWeekDate::from_date(date)
787    }
788}
789
790impl From<DateTime> for ISOWeekDate {
791    #[inline]
792    fn from(dt: DateTime) -> ISOWeekDate {
793        ISOWeekDate::from(dt.date())
794    }
795}
796
797impl From<Zoned> for ISOWeekDate {
798    #[inline]
799    fn from(zdt: Zoned) -> ISOWeekDate {
800        ISOWeekDate::from(zdt.date())
801    }
802}
803
804impl<'a> From<&'a Zoned> for ISOWeekDate {
805    #[inline]
806    fn from(zdt: &'a Zoned) -> ISOWeekDate {
807        ISOWeekDate::from(zdt.date())
808    }
809}
810
811#[cfg(test)]
812impl quickcheck::Arbitrary for ISOWeekDate {
813    fn arbitrary(g: &mut quickcheck::Gen) -> ISOWeekDate {
814        let year = ISOYear::arbitrary(g);
815        let week = ISOWeek::arbitrary(g);
816        let weekday = Weekday::arbitrary(g);
817        ISOWeekDate::new_ranged_constrain(year, week, weekday)
818    }
819
820    fn shrink(&self) -> alloc::boxed::Box<dyn Iterator<Item = ISOWeekDate>> {
821        alloc::boxed::Box::new(
822            (self.year_ranged(), self.week_ranged(), self.weekday())
823                .shrink()
824                .map(|(year, week, weekday)| {
825                    ISOWeekDate::new_ranged_constrain(year, week, weekday)
826                }),
827        )
828    }
829}
830
831/// Returns true if the given ISO year is a "long" year or not.
832///
833/// A "long" year is a year with 53 weeks. Otherwise, it's a "short" year
834/// with 52 weeks.
835fn is_long_year(year: ISOYear) -> bool {
836    // Inspired by: https://en.wikipedia.org/wiki/ISO_week_date#Weeks_per_year
837    let last = Date::new_ranged(year.rinto(), C(12).rinto(), C(31).rinto())
838        .expect("last day of year is always valid");
839    let weekday = last.weekday();
840    weekday == Weekday::Thursday
841        || (last.in_leap_year() && weekday == Weekday::Friday)
842}
843
844#[cfg(not(miri))]
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    quickcheck::quickcheck! {
850        fn prop_all_long_years_have_53rd_week(year: ISOYear) -> bool {
851            !is_long_year(year)
852                || ISOWeekDate::new(year.get(), 53, Weekday::Sunday).is_ok()
853        }
854
855        fn prop_prev_day_is_less(wd: ISOWeekDate) -> quickcheck::TestResult {
856            use crate::ToSpan;
857
858            if wd == ISOWeekDate::MIN {
859                return quickcheck::TestResult::discard();
860            }
861            let prev_date = wd.date().checked_add(-1.days()).unwrap();
862            quickcheck::TestResult::from_bool(prev_date.iso_week_date() < wd)
863        }
864
865        fn prop_next_day_is_greater(wd: ISOWeekDate) -> quickcheck::TestResult {
866            use crate::ToSpan;
867
868            if wd == ISOWeekDate::MAX {
869                return quickcheck::TestResult::discard();
870            }
871            let next_date = wd.date().checked_add(1.days()).unwrap();
872            quickcheck::TestResult::from_bool(wd < next_date.iso_week_date())
873        }
874    }
875}