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}