Skip to main content

rapport_temporal/
date.rs

1//! Sensible approach to dates, the way a human would. Easily parses to and from the iso friendly `YYYY-mm-dd` format, such as `2026-04-27`.
2
3use std::str::FromStr;
4
5use crate::{Error, recurrence::Interval};
6
7pub use chrono::NaiveDate;
8use chrono::{Datelike, Days, Months, TimeZone, Utc};
9use nonempty::NonEmpty;
10use serde::{Deserialize, Serialize};
11use strum::{EnumCount, VariantArray};
12
13#[derive(
14    Debug,
15    Clone,
16    Copy,
17    Serialize,
18    Deserialize,
19    Default,
20    PartialEq,
21    Eq,
22    PartialOrd,
23    Ord,
24    Hash,
25    derive_more::Display,
26)]
27#[display("{}", _0)]
28#[serde(transparent)]
29pub struct Date(NaiveDate);
30
31impl Date {
32    #[must_use]
33    pub fn into_iso_string(self) -> String {
34        self.0.format(ISO_DATE_FORMAT).to_string()
35    }
36
37    #[must_use]
38    pub fn weekday(self) -> Weekday {
39        Weekday::from(Datelike::weekday(&self.0))
40    }
41
42    #[must_use]
43    pub fn month(self) -> Month {
44        Month::from(self.0)
45    }
46
47    /// Provides the day of month for this dates
48    ///
49    /// # Panics
50    ///
51    /// Panics when `DayOfMonth` is outside of a valid `DayOfMonth` value, which should never happen.
52    #[must_use]
53    #[allow(clippy::expect_used)]
54    pub fn day_of_month(self) -> DayOfMonth {
55        DayOfMonth::from_value(Datelike::day(&self.0))
56            .expect("expecting all dates to have a day in a month")
57    }
58
59    #[must_use]
60    pub fn year(self) -> Year {
61        Year(self.0.year())
62    }
63
64    #[must_use]
65    pub fn day(self) -> u32 {
66        self.0.day()
67    }
68
69    #[must_use]
70    pub fn add_interval_days(self, value: Interval) -> Date {
71        self.add_days(value.get() as usize)
72    }
73
74    #[must_use]
75    pub fn add_days(self, value: usize) -> Date {
76        Self(
77            self.0
78                .checked_add_days(Days::new(value as u64))
79                .unwrap_or(self.0),
80        )
81    }
82
83    #[must_use]
84    pub fn sub_days(self, value: usize) -> Date {
85        Self(
86            self.0
87                .checked_sub_days(Days::new(value as u64))
88                .unwrap_or(self.0),
89        )
90    }
91
92    #[must_use]
93    pub const fn from_ymd_opt(year: Year, month: u32, day: u32) -> Option<Date> {
94        let year: i32 = year.into_i32();
95        let inner = NaiveDate::from_ymd_opt(year, month, day);
96        match inner {
97            Some(inner) => Some(Self(inner)),
98            None => None,
99        }
100    }
101
102    #[must_use]
103    pub fn day_after(self) -> Date {
104        self.0.succ_opt().map_or(self, Self)
105    }
106
107    #[must_use]
108    pub fn next_month(self) -> Date {
109        self.0.checked_add_months(Months::new(1)).map_or(self, Self)
110    }
111
112    #[must_use]
113    pub fn first_of_next_month(self) -> Self {
114        self.first_of_month()
115            .0
116            .checked_add_months(Months::new(1))
117            .map_or(self, Self)
118    }
119
120    #[must_use]
121    pub fn first_of_month(self) -> Self {
122        self.0.with_day(DayOfMonth::MIN).map_or(self, Self)
123    }
124
125    #[must_use]
126    pub fn first_of_year(self) -> Self {
127        self.first_of_month().0.with_month(1).map_or(self, Self)
128    }
129
130    #[must_use]
131    pub fn with_day(self, value: DayOfMonth) -> Option<Date> {
132        self.0.with_day(value.to_value()).map(Self)
133    }
134
135    pub const MIN: Self = Self(NaiveDate::MIN);
136
137    #[must_use]
138    pub fn is_valid_date_str(value: &str) -> bool {
139        Self::from_str(value).is_ok()
140    }
141
142    /// Converts the given string which is expected to be in ISO format (YYYY-mm-dd) into a Date.
143    ///
144    /// # Panics
145    ///
146    /// If the given &str is not in the valid format
147    #[must_use]
148    #[allow(clippy::expect_used)]
149    pub fn from_str_unchecked(value: &str) -> Date {
150        Self::from_str(value)
151            .expect("expecting value {value} to be in iso format for it to be parsed")
152    }
153
154    /// Returns the absolute number of days between two dates
155    #[must_use]
156    pub fn days_between(self, other: Date) -> usize {
157        let duration = self.0.signed_duration_since(other.0);
158        duration.num_days().abs().try_into().unwrap_or_default()
159    }
160
161    /// Returns signed difference in days (positive = self is in future, negative = self is in past)
162    #[must_use]
163    pub fn signed_days_from(self, reference: Date) -> i64 {
164        (self.0 - reference.0).num_days()
165    }
166
167    /// Get the next business day (Monday-Friday) after this date
168    #[must_use]
169    pub fn next_business_day(self) -> Self {
170        let next_day = self.add_days(1);
171        match next_day.weekday() {
172            Weekday::Saturday => next_day.add_days(2), // Skip to Monday
173            Weekday::Sunday => next_day.add_days(1),   // Skip to Monday
174            _ => next_day,                             // Weekday, so use it
175        }
176    }
177
178    #[must_use]
179    pub fn latest_sunday(self) -> Self {
180        match self.weekday() {
181            Weekday::Monday => self.sub_days(1),
182            Weekday::Tuesday => self.sub_days(2),
183            Weekday::Wednesday => self.sub_days(3),
184            Weekday::Thursday => self.sub_days(4),
185            Weekday::Friday => self.sub_days(5),
186            Weekday::Saturday => self.sub_days(6),
187            Weekday::Sunday => self,
188        }
189    }
190
191    #[must_use]
192    pub fn next_sunday(self) -> Self {
193        let days_until_sunday = match self.weekday() {
194            Weekday::Monday => 6,
195            Weekday::Tuesday => 5,
196            Weekday::Wednesday => 4,
197            Weekday::Thursday => 3,
198            Weekday::Friday => 2,
199            Weekday::Saturday => 1,
200            Weekday::Sunday => 7, // If today is Sunday, get next Sunday
201        };
202        self.add_days(days_until_sunday)
203    }
204
205    #[must_use]
206    pub fn next_saturday(self) -> Self {
207        let days_until_saturday = match self.weekday() {
208            Weekday::Monday => 5,
209            Weekday::Tuesday => 4,
210            Weekday::Wednesday => 3,
211            Weekday::Thursday => 2,
212            Weekday::Friday => 1,
213            Weekday::Saturday => 7, // If today is Saturday, get next Saturday
214            Weekday::Sunday => 6,
215        };
216        self.add_days(days_until_saturday)
217    }
218
219    #[must_use]
220    pub fn soonest_saturday(self) -> Self {
221        if self.weekday() == Weekday::Saturday {
222            self
223        } else {
224            self.next_saturday()
225        }
226    }
227
228    /// Get the next Monday after this date (or next Monday if today is Monday)
229    #[must_use]
230    pub fn next_monday(self) -> Self {
231        let days_until_monday = match self.weekday() {
232            Weekday::Monday => 7, // If today is Monday, get next Monday
233            Weekday::Tuesday => 6,
234            Weekday::Wednesday => 5,
235            Weekday::Thursday => 4,
236            Weekday::Friday => 3,
237            Weekday::Saturday => 2,
238            Weekday::Sunday => 1,
239        };
240        self.add_days(days_until_monday)
241    }
242
243    /// Add the specified number of months to this date
244    #[must_use]
245    pub fn add_months(self, months: impl Into<u32>) -> Self {
246        use chrono::Months;
247
248        let naive_date: chrono::NaiveDate = self.into();
249        let result = naive_date
250            .checked_add_months(Months::new(months.into()))
251            .unwrap_or(naive_date);
252
253        Self::from(result)
254    }
255
256    /// Add the specified number of years to this date
257    #[must_use]
258    pub fn add_years(self, years: u32) -> Self {
259        let years = years.try_into().unwrap_or(i32::MAX);
260        let new_year = self.year().saturating_add(years);
261        Self::from_ymd_opt(new_year, self.month().to_month_number(), self.day()).unwrap_or_else(
262            || {
263                // Handle Feb 29 on non-leap years by going to Feb 28
264                Self::from_ymd_opt(new_year, self.month().to_month_number(), 28).unwrap_or(self)
265            },
266        )
267    }
268
269    /// Subtract the specified number of years from this date
270    #[must_use]
271    pub fn sub_years(self, years: u32) -> Self {
272        let years = years.try_into().unwrap_or(i32::MAX);
273        let new_year = self.year().saturating_sub(years);
274        Self::from_ymd_opt(new_year, self.month().to_month_number(), self.day()).unwrap_or_else(
275            || {
276                // Handle Feb 29 on non-leap years by going to Feb 28
277                Self::from_ymd_opt(new_year, self.month().to_month_number(), 28).unwrap_or(self)
278            },
279        )
280    }
281
282    /// Subtract the specified number of months from this date.
283    #[must_use]
284    pub fn sub_months(self, months: u32) -> Self {
285        let naive_date: NaiveDate = self.into();
286        let result = naive_date
287            .checked_sub_months(Months::new(months))
288            .unwrap_or(naive_date);
289        Self::from(result)
290    }
291
292    /// Returns the most recent occurrence of the given weekday.
293    /// If today is that weekday, returns the *previous* occurrence (not today).
294    #[must_use]
295    pub fn last_weekday(self, weekday: Weekday) -> Self {
296        let today_weekday = self.weekday();
297        let days_back = if today_weekday == weekday {
298            7 // If today is the target weekday, go back a full week
299        } else {
300            let today_num = today_weekday.num_days_from_monday();
301            let target_num = weekday.num_days_from_monday();
302            if today_num > target_num {
303                today_num - target_num
304            } else {
305                7 - (target_num - today_num)
306            }
307        };
308        self.sub_days(days_back)
309    }
310
311    /// Returns the next occurrence of the given weekday.
312    /// If today is that weekday, returns the *next* occurrence (not today).
313    #[must_use]
314    pub fn next_weekday(self, weekday: Weekday) -> Self {
315        weekday.next_occurrence_after(self)
316    }
317
318    /// Returns the last day of the current month.
319    #[must_use]
320    pub fn last_day_of_month(self) -> Self {
321        self.first_of_next_month().sub_days(1)
322    }
323
324    /// Returns the last day of the next month.
325    #[must_use]
326    pub fn last_day_of_next_month(self) -> Self {
327        self.first_of_next_month().last_day_of_month()
328    }
329
330    /// Get January 1st of the next year
331    #[must_use]
332    pub fn next_january_first(self) -> Self {
333        let next_year = self.year().next();
334        Self::from_ymd_opt(next_year, 1, 1).unwrap_or(self)
335    }
336
337    /// January first of this year.
338    #[must_use]
339    pub fn january_first(year: Year) -> Option<Self> {
340        Self::from_ymd_opt(year, 1, 1)
341    }
342
343    #[must_use]
344    pub fn into_naive_date(self) -> NaiveDate {
345        self.0
346    }
347
348    #[must_use]
349    pub fn from_naive_date(value: NaiveDate) -> Self {
350        Self(value)
351    }
352
353    #[must_use]
354    pub fn year_month(self) -> YearMonth {
355        YearMonth::new(self.year(), self.month())
356    }
357
358    #[must_use]
359    pub fn end_of_day_utc(self) -> chrono::DateTime<Utc> {
360        Utc.from_utc_datetime(&self.0.and_hms_opt(23, 59, 59).unwrap_or_default())
361    }
362
363    #[must_use]
364    pub fn start_of_day_utc(self) -> chrono::DateTime<Utc> {
365        Utc.from_utc_datetime(&self.0.and_hms_opt(0, 0, 0).unwrap_or_default())
366    }
367
368    #[must_use]
369    pub fn today() -> Self {
370        Self::from(chrono::Local::now().date_naive())
371    }
372
373    #[must_use]
374    pub fn last_day_of_previous_month(self) -> Self {
375        self.first_of_month().sub_days(1)
376    }
377}
378
379impl FromStr for Date {
380    type Err = Error;
381
382    fn from_str(s: &str) -> Result<Self, Error> {
383        NaiveDate::parse_from_str(s, ISO_DATE_FORMAT)
384            .map(Self)
385            .map_err(|_| Error::InvalidDate)
386    }
387}
388
389impl From<NaiveDate> for Date {
390    fn from(value: NaiveDate) -> Self {
391        Self(value)
392    }
393}
394
395impl From<Date> for NaiveDate {
396    fn from(value: Date) -> Self {
397        value.into_naive_date()
398    }
399}
400
401impl std::ops::Sub for Date {
402    type Output = crate::time::Duration;
403
404    fn sub(self, other: Date) -> Self::Output {
405        let chrono_duration = self.0.signed_duration_since(other.0);
406        let abs_duration = chrono_duration.abs();
407
408        // Try to get nanoseconds first for precision
409        if let Some(nanos) = abs_duration.num_nanoseconds() {
410            crate::time::Duration {
411                nanos: nanos.abs().try_into().unwrap_or_default(),
412            }
413        } else {
414            // For very large durations, fall back to seconds
415            let secs = abs_duration
416                .num_seconds()
417                .abs()
418                .try_into()
419                .unwrap_or_default();
420            crate::time::Duration::from_secs(secs)
421        }
422    }
423}
424
425#[derive(
426    Debug,
427    Clone,
428    Copy,
429    Serialize,
430    Deserialize,
431    PartialEq,
432    Eq,
433    PartialOrd,
434    Ord,
435    Hash,
436    derive_more::Display,
437)]
438#[serde(transparent)]
439#[display("{_0}")]
440pub struct Year(i32);
441
442impl Year {
443    pub(crate) fn saturating_add(self, value: i32) -> Self {
444        Self(self.0.saturating_add(value))
445    }
446
447    pub(crate) fn saturating_sub(self, value: i32) -> Self {
448        Self(self.0.saturating_sub(value))
449    }
450
451    #[must_use]
452    pub fn previous(self) -> Self {
453        Self(self.0.saturating_sub(1))
454    }
455
456    pub(crate) fn next(self) -> Self {
457        self.saturating_add(1)
458    }
459
460    pub(crate) const fn into_i32(self) -> i32 {
461        self.0
462    }
463}
464
465impl From<i32> for Year {
466    fn from(value: i32) -> Self {
467        Self(value)
468    }
469}
470
471impl From<Year> for i32 {
472    fn from(value: Year) -> Self {
473        value.0
474    }
475}
476
477const ISO_DATE_FORMAT: &str = "%Y-%m-%d";
478
479#[derive(
480    Debug,
481    Clone,
482    Copy,
483    Serialize,
484    Deserialize,
485    PartialEq,
486    Eq,
487    Hash,
488    derive_more::Display,
489    derive_more::FromStr,
490    strum::VariantArray,
491    strum::EnumCount,
492)]
493pub enum Weekday {
494    #[display("Monday")]
495    Monday,
496    #[display("Tuesday")]
497    Tuesday,
498    #[display("Wednesday")]
499    Wednesday,
500    #[display("Thursday")]
501    Thursday,
502    #[display("Friday")]
503    Friday,
504    #[display("Saturday")]
505    Saturday,
506    #[display("Sunday")]
507    Sunday,
508}
509
510impl Weekday {
511    pub(crate) fn next_occurrence_after(self, date: Date) -> Date {
512        let after_weekday = date.weekday();
513
514        let days_to_add = if after_weekday == self {
515            // If today is the target day, return next week's occurrence
516            Self::COUNT
517        } else {
518            let after_num_days = after_weekday.num_days_from_monday();
519            let self_num_days = self.num_days_from_monday();
520
521            if self_num_days > after_num_days {
522                // target is later this week
523                self_num_days - after_num_days
524            } else {
525                // target is next week
526                Self::COUNT - after_num_days + self_num_days
527            }
528        };
529
530        date.add_days(days_to_add)
531    }
532
533    /// Returns the number of days from Monday (Monday = 0, Sunday = 6).
534    #[must_use]
535    pub fn num_days_from_monday(self) -> usize {
536        match self {
537            Weekday::Monday => 0,
538            Weekday::Tuesday => 1,
539            Weekday::Wednesday => 2,
540            Weekday::Thursday => 3,
541            Weekday::Friday => 4,
542            Weekday::Saturday => 5,
543            Weekday::Sunday => 6,
544        }
545    }
546
547    pub(crate) fn next_occurrence_after_days(days: &NonEmpty<Self>, date: Date) -> Date {
548        days.iter()
549            .map(|day| day.next_occurrence_after(date))
550            .min()
551            .unwrap_or(date)
552    }
553
554    pub(crate) fn is_weekend(self) -> bool {
555        self == Weekday::Saturday || self == Weekday::Sunday
556    }
557
558    pub(crate) fn is_weekday(self) -> bool {
559        !self.is_weekend()
560    }
561
562    #[must_use]
563    pub fn first_initial(&self) -> String {
564        let initial = match self {
565            Weekday::Monday => "M",
566            Weekday::Tuesday | Weekday::Thursday => "T",
567            Weekday::Wednesday => "W",
568            Weekday::Friday => "F",
569            Weekday::Saturday | Weekday::Sunday => "S",
570        };
571        initial.to_owned()
572    }
573}
574
575impl From<Weekday> for NonEmpty<Weekday> {
576    fn from(value: Weekday) -> Self {
577        NonEmpty::singleton(value)
578    }
579}
580
581#[derive(
582    Debug,
583    Clone,
584    Copy,
585    Serialize,
586    Deserialize,
587    PartialEq,
588    Eq,
589    PartialOrd,
590    Ord,
591    Hash,
592    derive_more::Display,
593    derive_more::FromStr,
594    strum::VariantArray,
595)]
596pub enum Month {
597    #[display("January")]
598    January,
599    #[display("February")]
600    February,
601    #[display("March")]
602    March,
603    #[display("April")]
604    April,
605    #[display("May")]
606    May,
607    #[display("June")]
608    June,
609    #[display("July")]
610    July,
611    #[display("August")]
612    August,
613    #[display("September")]
614    September,
615    #[display("October")]
616    October,
617    #[display("November")]
618    November,
619    #[display("December")]
620    December,
621}
622
623impl Month {
624    #[must_use]
625    pub fn to_month_number(self) -> u32 {
626        match self {
627            Month::January => 1,
628            Month::February => 2,
629            Month::March => 3,
630            Month::April => 4,
631            Month::May => 5,
632            Month::June => 6,
633            Month::July => 7,
634            Month::August => 8,
635            Month::September => 9,
636            Month::October => 10,
637            Month::November => 11,
638            Month::December => 12,
639        }
640    }
641
642    /// Convert month number (1-12) to Month enum.
643    #[must_use]
644    pub fn from_number(num: u32) -> Option<Self> {
645        match num {
646            1 => Some(Month::January),
647            2 => Some(Month::February),
648            3 => Some(Month::March),
649            4 => Some(Month::April),
650            5 => Some(Month::May),
651            6 => Some(Month::June),
652            7 => Some(Month::July),
653            8 => Some(Month::August),
654            9 => Some(Month::September),
655            10 => Some(Month::October),
656            11 => Some(Month::November),
657            12 => Some(Month::December),
658            _ => None,
659        }
660    }
661
662    #[must_use]
663    pub fn short_description(&self) -> &'static str {
664        match self {
665            Month::January => "Jan",
666            Month::February => "Feb",
667            Month::March => "Mar",
668            Month::April => "Apr",
669            Month::May => "May",
670            Month::June => "Jun",
671            Month::July => "Jul",
672            Month::August => "Aug",
673            Month::September => "Sep",
674            Month::October => "Oct",
675            Month::November => "Nov",
676            Month::December => "Dec",
677        }
678    }
679
680    #[must_use]
681    pub fn months_until(value: Self) -> Vec<Self> {
682        let mut months = Vec::new();
683        for month in Month::VARIANTS {
684            months.push(*month);
685            if month == &value {
686                break;
687            }
688        }
689        months
690    }
691
692    #[must_use]
693    pub fn next(self) -> Self {
694        match self {
695            Month::January => Month::February,
696            Month::February => Month::March,
697            Month::March => Month::April,
698            Month::April => Month::May,
699            Month::May => Month::June,
700            Month::June => Month::July,
701            Month::July => Month::August,
702            Month::August => Month::September,
703            Month::September => Month::October,
704            Month::October => Month::November,
705            Month::November => Month::December,
706            Month::December => Month::January,
707        }
708    }
709}
710
711impl From<Month> for NonEmpty<Month> {
712    fn from(value: Month) -> Self {
713        NonEmpty::singleton(value)
714    }
715}
716
717impl From<NaiveDate> for Month {
718    fn from(value: NaiveDate) -> Self {
719        match value.month0() {
720            0 => Month::January,
721            1 => Month::February,
722            2 => Month::March,
723            3 => Month::April,
724            4 => Month::May,
725            5 => Month::June,
726            6 => Month::July,
727            7 => Month::August,
728            8 => Month::September,
729            9 => Month::October,
730            10 => Month::November,
731            11 => Month::December,
732            _ => unreachable!("Invalid month index from NaiveDate"),
733        }
734    }
735}
736
737impl From<Weekday> for chrono::Weekday {
738    fn from(value: Weekday) -> chrono::Weekday {
739        match value {
740            Weekday::Monday => chrono::Weekday::Mon,
741            Weekday::Tuesday => chrono::Weekday::Tue,
742            Weekday::Wednesday => chrono::Weekday::Wed,
743            Weekday::Thursday => chrono::Weekday::Thu,
744            Weekday::Friday => chrono::Weekday::Fri,
745            Weekday::Saturday => chrono::Weekday::Sat,
746            Weekday::Sunday => chrono::Weekday::Sun,
747        }
748    }
749}
750
751impl From<chrono::Weekday> for Weekday {
752    fn from(value: chrono::Weekday) -> Self {
753        match value {
754            chrono::Weekday::Mon => Self::Monday,
755            chrono::Weekday::Tue => Self::Tuesday,
756            chrono::Weekday::Wed => Self::Wednesday,
757            chrono::Weekday::Thu => Self::Thursday,
758            chrono::Weekday::Fri => Self::Friday,
759            chrono::Weekday::Sat => Self::Saturday,
760            chrono::Weekday::Sun => Self::Sunday,
761        }
762    }
763}
764
765#[derive(
766    Debug,
767    Clone,
768    Copy,
769    Serialize,
770    Deserialize,
771    PartialEq,
772    Eq,
773    PartialOrd,
774    Ord,
775    derive_more::Display,
776    strum::VariantArray,
777)]
778pub enum DayOfMonth {
779    #[display("1st")]
780    First,
781    #[display("2nd")]
782    Second,
783    #[display("3rd")]
784    Third,
785    #[display("4th")]
786    Fourth,
787    #[display("5th")]
788    Fifth,
789    #[display("6th")]
790    Sixth,
791    #[display("7th")]
792    Seventh,
793    #[display("8th")]
794    Eighth,
795    #[display("9th")]
796    Ninth,
797    #[display("10th")]
798    Tenth,
799    #[display("11th")]
800    Eleventh,
801    #[display("12th")]
802    Twelfth,
803    #[display("13th")]
804    Thirteenth,
805    #[display("14th")]
806    Fourteenth,
807    #[display("15th")]
808    Fifteenth,
809    #[display("16th")]
810    Sixteenth,
811    #[display("17th")]
812    Seventeenth,
813    #[display("18th")]
814    Eighteenth,
815    #[display("19th")]
816    Nineteenth,
817    #[display("20th")]
818    Twentieth,
819    #[display("21st")]
820    TwentyFirst,
821    #[display("22nd")]
822    TwentySecond,
823    #[display("23rd")]
824    TwentyThird,
825    #[display("24th")]
826    TwentyFourth,
827    #[display("25th")]
828    TwentyFifth,
829    #[display("26th")]
830    TwentySixth,
831    #[display("27th")]
832    TwentySeventh,
833    #[display("28th")]
834    TwentyEighth,
835    #[display("29th")]
836    TwentyNinth,
837    #[display("30th")]
838    Thirtieth,
839    #[display("31st")]
840    ThirtyFirst,
841}
842
843impl DayOfMonth {
844    pub(crate) const MIN: u32 = 1;
845    const MAX: u32 = 31;
846
847    pub(crate) fn from_value(value: u32) -> Option<Self> {
848        match value {
849            1 => Some(Self::First),
850            2 => Some(Self::Second),
851            3 => Some(Self::Third),
852            4 => Some(Self::Fourth),
853            5 => Some(Self::Fifth),
854            6 => Some(Self::Sixth),
855            7 => Some(Self::Seventh),
856            8 => Some(Self::Eighth),
857            9 => Some(Self::Ninth),
858            10 => Some(Self::Tenth),
859            11 => Some(Self::Eleventh),
860            12 => Some(Self::Twelfth),
861            13 => Some(Self::Thirteenth),
862            14 => Some(Self::Fourteenth),
863            15 => Some(Self::Fifteenth),
864            16 => Some(Self::Sixteenth),
865            17 => Some(Self::Seventeenth),
866            18 => Some(Self::Eighteenth),
867            19 => Some(Self::Nineteenth),
868            20 => Some(Self::Twentieth),
869            21 => Some(Self::TwentyFirst),
870            22 => Some(Self::TwentySecond),
871            23 => Some(Self::TwentyThird),
872            24 => Some(Self::TwentyFourth),
873            25 => Some(Self::TwentyFifth),
874            26 => Some(Self::TwentySixth),
875            27 => Some(Self::TwentySeventh),
876            28 => Some(Self::TwentyEighth),
877            29 => Some(Self::TwentyNinth),
878            30 => Some(Self::Thirtieth),
879            31 => Some(Self::ThirtyFirst),
880            _ => None,
881        }
882    }
883
884    #[must_use]
885    pub fn to_value(self) -> u32 {
886        match self {
887            Self::First => 1,
888            Self::Second => 2,
889            Self::Third => 3,
890            Self::Fourth => 4,
891            Self::Fifth => 5,
892            Self::Sixth => 6,
893            Self::Seventh => 7,
894            Self::Eighth => 8,
895            Self::Ninth => 9,
896            Self::Tenth => 10,
897            Self::Eleventh => 11,
898            Self::Twelfth => 12,
899            Self::Thirteenth => 13,
900            Self::Fourteenth => 14,
901            Self::Fifteenth => 15,
902            Self::Sixteenth => 16,
903            Self::Seventeenth => 17,
904            Self::Eighteenth => 18,
905            Self::Nineteenth => 19,
906            Self::Twentieth => 20,
907            Self::TwentyFirst => 21,
908            Self::TwentySecond => 22,
909            Self::TwentyThird => 23,
910            Self::TwentyFourth => 24,
911            Self::TwentyFifth => 25,
912            Self::TwentySixth => 26,
913            Self::TwentySeventh => 27,
914            Self::TwentyEighth => 28,
915            Self::TwentyNinth => 29,
916            Self::Thirtieth => 30,
917            Self::ThirtyFirst => 31,
918        }
919    }
920
921    pub(crate) fn range() -> std::ops::RangeInclusive<u32> {
922        Self::MIN..=Self::MAX
923    }
924}
925
926impl From<DayOfMonth> for NonEmpty<DayOfMonth> {
927    fn from(value: DayOfMonth) -> Self {
928        NonEmpty::singleton(value)
929    }
930}
931
932impl std::str::FromStr for DayOfMonth {
933    type Err = crate::Error;
934
935    fn from_str(s: &str) -> Result<Self, Self::Err> {
936        match s {
937            "1st" => Ok(Self::First),
938            "2nd" => Ok(Self::Second),
939            "3rd" => Ok(Self::Third),
940            "4th" => Ok(Self::Fourth),
941            "5th" => Ok(Self::Fifth),
942            "6th" => Ok(Self::Sixth),
943            "7th" => Ok(Self::Seventh),
944            "8th" => Ok(Self::Eighth),
945            "9th" => Ok(Self::Ninth),
946            "10th" => Ok(Self::Tenth),
947            "11th" => Ok(Self::Eleventh),
948            "12th" => Ok(Self::Twelfth),
949            "13th" => Ok(Self::Thirteenth),
950            "14th" => Ok(Self::Fourteenth),
951            "15th" => Ok(Self::Fifteenth),
952            "16th" => Ok(Self::Sixteenth),
953            "17th" => Ok(Self::Seventeenth),
954            "18th" => Ok(Self::Eighteenth),
955            "19th" => Ok(Self::Nineteenth),
956            "20th" => Ok(Self::Twentieth),
957            "21st" => Ok(Self::TwentyFirst),
958            "22nd" => Ok(Self::TwentySecond),
959            "23rd" => Ok(Self::TwentyThird),
960            "24th" => Ok(Self::TwentyFourth),
961            "25th" => Ok(Self::TwentyFifth),
962            "26th" => Ok(Self::TwentySixth),
963            "27th" => Ok(Self::TwentySeventh),
964            "28th" => Ok(Self::TwentyEighth),
965            "29th" => Ok(Self::TwentyNinth),
966            "30th" => Ok(Self::Thirtieth),
967            "31st" => Ok(Self::ThirtyFirst),
968            _ => Err(crate::Error::InvalidDate),
969        }
970    }
971}
972
973#[derive(
974    Debug,
975    Clone,
976    Copy,
977    Serialize,
978    Deserialize,
979    PartialEq,
980    Eq,
981    PartialOrd,
982    Ord,
983    Hash,
984    derive_more::Display,
985)]
986#[display("{year}-{month}")]
987pub struct YearMonth {
988    year: Year,
989    month: Month,
990}
991
992impl YearMonth {
993    pub fn new(year: impl Into<Year>, month: Month) -> Self {
994        let year = year.into();
995        Self { year, month }
996    }
997
998    #[must_use]
999    pub fn year(self) -> Year {
1000        self.year
1001    }
1002
1003    #[must_use]
1004    pub fn month(self) -> Month {
1005        self.month
1006    }
1007
1008    #[must_use]
1009    pub fn first_day(self) -> Date {
1010        Date::from_ymd_opt(self.year, self.month.to_month_number(), 1).unwrap_or_default()
1011    }
1012
1013    /// Last day of this month.
1014    #[must_use]
1015    pub fn last_day(self) -> Date {
1016        // Go to first of next month, then subtract one day
1017        self.first_day().first_of_next_month().sub_days(1)
1018    }
1019
1020    #[must_use]
1021    pub fn to_numeric_string(self) -> String {
1022        format!(
1023            "{}-{:02}",
1024            i32::from(self.year),
1025            self.month.to_month_number()
1026        )
1027    }
1028
1029    /// Parse a year-month string like "2025-01" or "2025-12".
1030    #[must_use]
1031    pub fn parse(input: &str) -> Option<YearMonth> {
1032        let input = input.trim();
1033        let parts: Vec<&str> = input.split('-').collect();
1034        if parts.len() != 2 {
1035            return None;
1036        }
1037
1038        let year: i32 = parts[0].parse().ok()?;
1039        let month_num: u32 = parts[1].parse().ok()?;
1040        let month = Month::from_number(month_num)?;
1041
1042        Some(YearMonth::new(year, month))
1043    }
1044}
1045
1046impl From<Date> for YearMonth {
1047    fn from(value: Date) -> Self {
1048        value.year_month()
1049    }
1050}
1051
1052/// A calendar quarter (Q1-Q4).
1053#[derive(
1054    Debug,
1055    Clone,
1056    Copy,
1057    Serialize,
1058    Deserialize,
1059    PartialEq,
1060    Eq,
1061    PartialOrd,
1062    Ord,
1063    Hash,
1064    derive_more::Display,
1065    strum::VariantArray,
1066)]
1067pub enum Quarter {
1068    #[display("Q1")]
1069    Q1,
1070    #[display("Q2")]
1071    Q2,
1072    #[display("Q3")]
1073    Q3,
1074    #[display("Q4")]
1075    Q4,
1076}
1077
1078impl Quarter {
1079    /// The starting month of this quarter.
1080    #[must_use]
1081    pub fn first_month(self) -> Month {
1082        match self {
1083            Quarter::Q1 => Month::January,
1084            Quarter::Q2 => Month::April,
1085            Quarter::Q3 => Month::July,
1086            Quarter::Q4 => Month::October,
1087        }
1088    }
1089
1090    /// The ending month of this quarter.
1091    #[must_use]
1092    pub fn last_month(self) -> Month {
1093        match self {
1094            Quarter::Q1 => Month::March,
1095            Quarter::Q2 => Month::June,
1096            Quarter::Q3 => Month::September,
1097            Quarter::Q4 => Month::December,
1098        }
1099    }
1100
1101    /// Parse a quarter number (1-4) into a Quarter.
1102    #[must_use]
1103    pub fn from_number(num: u8) -> Option<Self> {
1104        match num {
1105            1 => Some(Quarter::Q1),
1106            2 => Some(Quarter::Q2),
1107            3 => Some(Quarter::Q3),
1108            4 => Some(Quarter::Q4),
1109            _ => None,
1110        }
1111    }
1112
1113    /// Get the quarter number (1-4).
1114    #[must_use]
1115    pub fn to_number(self) -> u8 {
1116        match self {
1117            Quarter::Q1 => 1,
1118            Quarter::Q2 => 2,
1119            Quarter::Q3 => 3,
1120            Quarter::Q4 => 4,
1121        }
1122    }
1123}
1124
1125/// A calendar quarter in a specific year.
1126#[derive(
1127    Debug,
1128    Clone,
1129    Copy,
1130    Serialize,
1131    Deserialize,
1132    PartialEq,
1133    Eq,
1134    PartialOrd,
1135    Ord,
1136    Hash,
1137    derive_more::Display,
1138)]
1139#[display("{year}-{quarter}")]
1140pub struct YearQuarter {
1141    year: Year,
1142    quarter: Quarter,
1143}
1144
1145impl YearQuarter {
1146    #[must_use]
1147    pub fn new(year: impl Into<Year>, quarter: Quarter) -> Self {
1148        Self {
1149            year: year.into(),
1150            quarter,
1151        }
1152    }
1153
1154    #[must_use]
1155    pub fn year(self) -> Year {
1156        self.year
1157    }
1158
1159    #[must_use]
1160    pub fn quarter(self) -> Quarter {
1161        self.quarter
1162    }
1163
1164    /// First day of the quarter.
1165    #[must_use]
1166    pub fn first_day(self) -> Date {
1167        Date::from_ymd_opt(self.year, self.quarter.first_month().to_month_number(), 1)
1168            .unwrap_or_default()
1169    }
1170
1171    /// Last day of the quarter.
1172    #[must_use]
1173    pub fn last_day(self) -> Date {
1174        let (month, day) = match self.quarter {
1175            Quarter::Q1 => (3, 31),  // March 31
1176            Quarter::Q2 => (6, 30),  // June 30
1177            Quarter::Q3 => (9, 30),  // September 30
1178            Quarter::Q4 => (12, 31), // December 31
1179        };
1180        Date::from_ymd_opt(self.year, month, day).unwrap_or_default()
1181    }
1182
1183    /// Parse a year-quarter string like "2025-Q1" or "2025-q1".
1184    #[must_use]
1185    pub fn parse(input: &str) -> Option<Self> {
1186        let input = input.trim();
1187        let parts: Vec<&str> = input.split('-').collect();
1188        if parts.len() != 2 {
1189            return None;
1190        }
1191
1192        let year: i32 = parts[0].parse().ok()?;
1193        let quarter_str = parts[1].to_lowercase();
1194        let quarter_num: u8 = quarter_str.strip_prefix('q')?.parse().ok()?;
1195        let quarter = Quarter::from_number(quarter_num)?;
1196
1197        Some(Self::new(year, quarter))
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use std::str::FromStr;
1204
1205    use super::*;
1206    use claims::{assert_err, assert_ok, assert_some};
1207    use pretty_assertions::assert_eq;
1208    use rstest::rstest;
1209    use tracing_test::traced_test;
1210
1211    const SAMPLE_DATE_STR: &str = "2025-01-30";
1212    static SAMPLE_DATE: Date = assert_some!(Date::from_ymd_opt(Year(2025), 1, 30));
1213
1214    #[test]
1215    fn test_to_iso_string() {
1216        assert_eq!(SAMPLE_DATE.into_iso_string(), SAMPLE_DATE_STR);
1217        assert!(Date::is_valid_date_str(SAMPLE_DATE_STR));
1218    }
1219
1220    #[test]
1221    fn test_try_from_iso_string_valid() {
1222        let result = assert_ok!(Date::from_str(SAMPLE_DATE_STR));
1223        assert_eq!(result, SAMPLE_DATE);
1224    }
1225
1226    #[test]
1227    fn test_try_from_iso_string_invalid() {
1228        let invalid_date_str = "January 1, 2025"; // not the iso format
1229        assert_err!(Date::from_str(invalid_date_str));
1230        assert!(!Date::is_valid_date_str(invalid_date_str));
1231    }
1232
1233    #[rstest]
1234    #[case::same_day("2025-06-07", Weekday::Saturday, "2025-06-14")]
1235    #[case::next_day("2025-06-07", Weekday::Sunday, "2025-06-08")]
1236    #[case::monday("2025-06-07", Weekday::Monday, "2025-06-09")]
1237    #[case::tuesday("2025-06-07", Weekday::Tuesday, "2025-06-10")]
1238    #[case::wednesday("2025-06-07", Weekday::Wednesday, "2025-06-11")]
1239    #[case::thursday("2025-06-07", Weekday::Thursday, "2025-06-12")]
1240    #[case::friday("2025-06-07", Weekday::Friday, "2025-06-13")]
1241    #[case::next_month("2025-06-30", Weekday::Friday, "2025-07-04")]
1242    #[case::next_year("2025-12-29", Weekday::Friday, "2026-01-02")]
1243    fn weekday_next_occurrence_after(
1244        #[case] from: &str,
1245        #[case] day: Weekday,
1246        #[case] expected: &str,
1247    ) {
1248        let from = Date::from_str_unchecked(from);
1249        let expected = Date::from_str_unchecked(expected);
1250        let actual = day.next_occurrence_after(from);
1251
1252        assert_eq!(actual, expected);
1253    }
1254
1255    #[traced_test]
1256    #[rstest]
1257    #[case::monday(Weekday::Monday, true)]
1258    #[case::tuesday(Weekday::Tuesday, true)]
1259    #[case::wednesday(Weekday::Wednesday, true)]
1260    #[case::thursday(Weekday::Thursday, true)]
1261    #[case::friday(Weekday::Friday, true)]
1262    #[case::saturday(Weekday::Saturday, false)]
1263    #[case::sunday(Weekday::Sunday, false)]
1264    fn weekday_is_weekday(#[case] input: Weekday, #[case] expected: bool) {
1265        let actual_weekday = input.is_weekday();
1266        let actual_weekend = input.is_weekend();
1267        assert_eq!(
1268            actual_weekday, expected,
1269            "expecting {input} weekday to be {expected}"
1270        );
1271        assert_eq!(
1272            actual_weekend, !expected,
1273            "expecting {input} is weekend to {}",
1274            !expected
1275        );
1276    }
1277
1278    #[rstest]
1279    #[case::january("2025-01-15", Month::January)]
1280    #[case::february("2025-02-28", Month::February)]
1281    #[case::march("2025-03-10", Month::March)]
1282    #[case::april("2025-04-01", Month::April)]
1283    #[case::may("2025-05-31", Month::May)]
1284    #[case::june("2025-06-15", Month::June)]
1285    #[case::july("2025-07-04", Month::July)]
1286    #[case::august("2025-08-20", Month::August)]
1287    #[case::september("2025-09-30", Month::September)]
1288    #[case::october("2025-10-12", Month::October)]
1289    #[case::november("2025-11-25", Month::November)]
1290    #[case::december("2025-12-31", Month::December)]
1291    #[case::january_different_year("2024-01-01", Month::January)]
1292    #[case::february_leap_year("2024-02-29", Month::February)]
1293    #[case::december_different_year("2026-12-01", Month::December)]
1294    fn date_has_month(#[case] date: &str, #[case] expected: Month) {
1295        let date = Date::from_str_unchecked(date);
1296
1297        let actual = date.month();
1298
1299        assert_eq!(actual, expected);
1300    }
1301
1302    #[rstest]
1303    #[case::first("1st", DayOfMonth::First)]
1304    #[case::second("2nd", DayOfMonth::Second)]
1305    #[case::third("3rd", DayOfMonth::Third)]
1306    #[case::fourth("4th", DayOfMonth::Fourth)]
1307    #[case::fifth("5th", DayOfMonth::Fifth)]
1308    #[case::sixth("6th", DayOfMonth::Sixth)]
1309    #[case::seventh("7th", DayOfMonth::Seventh)]
1310    #[case::eighth("8th", DayOfMonth::Eighth)]
1311    #[case::ninth("9th", DayOfMonth::Ninth)]
1312    #[case::tenth("10th", DayOfMonth::Tenth)]
1313    #[case::eleventh("11th", DayOfMonth::Eleventh)]
1314    #[case::twelfth("12th", DayOfMonth::Twelfth)]
1315    #[case::thirteenth("13th", DayOfMonth::Thirteenth)]
1316    #[case::fourteenth("14th", DayOfMonth::Fourteenth)]
1317    #[case::fifteenth("15th", DayOfMonth::Fifteenth)]
1318    #[case::sixteenth("16th", DayOfMonth::Sixteenth)]
1319    #[case::seventeenth("17th", DayOfMonth::Seventeenth)]
1320    #[case::eighteenth("18th", DayOfMonth::Eighteenth)]
1321    #[case::nineteenth("19th", DayOfMonth::Nineteenth)]
1322    #[case::twentieth("20th", DayOfMonth::Twentieth)]
1323    #[case::twenty_first("21st", DayOfMonth::TwentyFirst)]
1324    #[case::twenty_second("22nd", DayOfMonth::TwentySecond)]
1325    #[case::twenty_third("23rd", DayOfMonth::TwentyThird)]
1326    #[case::twenty_fourth("24th", DayOfMonth::TwentyFourth)]
1327    #[case::twenty_fifth("25th", DayOfMonth::TwentyFifth)]
1328    #[case::twenty_sixth("26th", DayOfMonth::TwentySixth)]
1329    #[case::twenty_seventh("27th", DayOfMonth::TwentySeventh)]
1330    #[case::twenty_eighth("28th", DayOfMonth::TwentyEighth)]
1331    #[case::twenty_ninth("29th", DayOfMonth::TwentyNinth)]
1332    #[case::thirtieth("30th", DayOfMonth::Thirtieth)]
1333    #[case::thirty_first("31st", DayOfMonth::ThirtyFirst)]
1334    fn day_of_month_parses_and_prints(#[case] input: &str, #[case] expected: DayOfMonth) {
1335        let actual = assert_ok!(DayOfMonth::from_str(input));
1336
1337        assert_eq!(
1338            actual, expected,
1339            "expecting {input} to parse as expected {expected}"
1340        );
1341        assert_eq!(
1342            expected.to_string(),
1343            input,
1344            "expecting day of month to print back out to the same as it was entered in"
1345        );
1346    }
1347
1348    #[rstest]
1349    #[case::same_date("2025-01-30", "2025-01-30", 0)]
1350    #[case::one_day_apart("2025-01-31", "2025-01-30", 24 * 60 * 60)] // 1 day in seconds
1351    #[case::three_days_apart("2025-02-02", "2025-01-30", 3 * 24 * 60 * 60)] // 3 days in seconds
1352    #[case::reversed_order("2025-01-30", "2025-02-02", 3 * 24 * 60 * 60)] // should be absolute difference
1353    fn date_subtraction_yields_duration(
1354        #[case] date1: &str,
1355        #[case] date2: &str,
1356        #[case] expected_seconds: u64,
1357    ) {
1358        let date1 = Date::from_str_unchecked(date1);
1359        let date2 = Date::from_str_unchecked(date2);
1360
1361        let duration = date1 - date2;
1362
1363        assert_eq!(
1364            duration.as_secs(),
1365            expected_seconds,
1366            "expected duration of {} seconds between {} and {}",
1367            expected_seconds,
1368            date1,
1369            date2
1370        );
1371    }
1372
1373    #[rstest]
1374    #[case("2025-01-01", "2025-01-01")]
1375    #[case("2025-01-02", "2025-01-01")]
1376    #[case("2025-06-30", "2025-01-01")]
1377    #[case("2025-12-31", "2025-01-01")]
1378    #[case("2026-01-01", "2026-01-01")]
1379    fn first_of_year(#[case] date: &str, #[case] expected: &str) {
1380        let date = Date::from_str_unchecked(date);
1381        let expected = Date::from_str_unchecked(expected);
1382        let actual = date.first_of_year();
1383        assert_eq!(actual, expected);
1384    }
1385
1386    #[rstest]
1387    #[case::january(Month::January, vec![Month::January])]
1388    #[case::february(Month::February, vec![Month::January, Month::February])]
1389    #[case::march(Month::March, vec![Month::January, Month::February, Month::March])]
1390    #[case::april(Month::April, vec![Month::January, Month::February, Month::March, Month::April])]
1391    #[case::may(Month::May, vec![Month::January, Month::February, Month::March, Month::April, Month::May])]
1392    #[case::june(Month::June, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June])]
1393    #[case::july(Month::July, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July])]
1394    #[case::august(Month::August, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July, Month::August])]
1395    #[case::september(Month::September, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July, Month::August, Month::September])]
1396    #[case::october(Month::October, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July, Month::August, Month::September, Month::October])]
1397    #[case::november(Month::November, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July, Month::August, Month::September, Month::October, Month::November])]
1398    #[case::december(Month::December, vec![Month::January, Month::February, Month::March, Month::April, Month::May, Month::June, Month::July, Month::August, Month::September, Month::October, Month::November, Month::December])]
1399    fn months_until(#[case] value: Month, #[case] expected: Vec<Month>) {
1400        let actual = Month::months_until(value);
1401        assert_eq!(actual, expected);
1402    }
1403
1404    #[rstest]
1405    #[case::january(YearMonth::new(Year(2025), Month::January), "2025-01-01")]
1406    #[case::february(YearMonth::new(Year(2025), Month::February), "2025-02-01")]
1407    #[case::march(YearMonth::new(Year(2025), Month::March), "2025-03-01")]
1408    #[case::april(YearMonth::new(Year(2025), Month::April), "2025-04-01")]
1409    #[case::may(YearMonth::new(Year(2025), Month::May), "2025-05-01")]
1410    #[case::june(YearMonth::new(Year(2025), Month::June), "2025-06-01")]
1411    #[case::july(YearMonth::new(Year(2025), Month::July), "2025-07-01")]
1412    #[case::august(YearMonth::new(Year(2025), Month::August), "2025-08-01")]
1413    #[case::september(YearMonth::new(Year(2025), Month::September), "2025-09-01")]
1414    #[case::october(YearMonth::new(Year(2025), Month::October), "2025-10-01")]
1415    #[case::november(YearMonth::new(Year(2025), Month::November), "2025-11-01")]
1416    #[case::december(YearMonth::new(Year(2025), Month::December), "2025-12-01")]
1417    fn first_day_of_month(#[case] year_month: YearMonth, #[case] expected: &str) {
1418        let actual = year_month.first_day();
1419        assert_eq!(actual.into_iso_string(), expected);
1420
1421        let actual = actual.year_month();
1422        assert_eq!(actual, year_month);
1423    }
1424
1425    #[test]
1426    fn next_year() {
1427        let starting = Year(2024);
1428        let expected = Year(2025);
1429        let actual = starting.next();
1430        assert_eq!(actual, expected);
1431    }
1432
1433    #[rstest]
1434    #[case::january(Month::January, Month::February)]
1435    #[case::february(Month::February, Month::March)]
1436    #[case::march(Month::March, Month::April)]
1437    #[case::april(Month::April, Month::May)]
1438    #[case::may(Month::May, Month::June)]
1439    #[case::june(Month::June, Month::July)]
1440    #[case::july(Month::July, Month::August)]
1441    #[case::august(Month::August, Month::September)]
1442    #[case::september(Month::September, Month::October)]
1443    #[case::october(Month::October, Month::November)]
1444    #[case::november(Month::November, Month::December)]
1445    #[case::december(Month::December, Month::January)]
1446    fn next_month(#[case] starting: Month, #[case] expected: Month) {
1447        let actual = starting.next();
1448        assert_eq!(actual, expected);
1449    }
1450
1451    #[rstest]
1452    #[case::same_date("2026-02-02", "2026-02-02", 0)]
1453    #[case::one_day_future("2026-02-03", "2026-02-02", 1)]
1454    #[case::two_days_future("2026-02-04", "2026-02-02", 2)]
1455    #[case::one_day_past("2026-02-01", "2026-02-02", -1)]
1456    #[case::two_weeks_past("2026-01-19", "2026-02-02", -14)]
1457    #[case::two_weeks_future("2026-02-16", "2026-02-02", 14)]
1458    #[case::across_years("2027-02-02", "2026-02-02", 365)]
1459    fn signed_days_from(#[case] date: &str, #[case] reference: &str, #[case] expected: i64) {
1460        let date = Date::from_str_unchecked(date);
1461        let reference = Date::from_str_unchecked(reference);
1462
1463        let actual = date.signed_days_from(reference);
1464
1465        assert_eq!(actual, expected);
1466    }
1467
1468    #[test]
1469    fn end_of_day_utc_should_return_23_59_59() {
1470        let date = Date::from_str_unchecked("2026-03-15");
1471
1472        let result = date.end_of_day_utc();
1473
1474        assert_eq!(result.to_string(), "2026-03-15 23:59:59 UTC");
1475    }
1476
1477    #[test]
1478    fn start_of_day_utc_should_return_00_00_00() {
1479        let date = Date::from_str_unchecked("2026-03-15");
1480
1481        let result = date.start_of_day_utc();
1482
1483        assert_eq!(result.to_string(), "2026-03-15 00:00:00 UTC");
1484    }
1485
1486    #[rstest]
1487    #[case::mid_month("2026-03-15", "2026-02-28")]
1488    #[case::first_of_month("2026-03-01", "2026-02-28")]
1489    #[case::january_wraps_to_december("2026-01-10", "2025-12-31")]
1490    #[case::leap_year("2024-03-15", "2024-02-29")]
1491    fn last_day_of_previous_month_should_return_correct_date(
1492        #[case] input: &str,
1493        #[case] expected: &str,
1494    ) {
1495        let date = Date::from_str_unchecked(input);
1496
1497        let actual = date.last_day_of_previous_month();
1498
1499        assert_eq!(actual, Date::from_str_unchecked(expected));
1500    }
1501
1502    #[rstest]
1503    #[case::january(2025, Month::January, "2025-01")]
1504    #[case::september(2025, Month::September, "2025-09")]
1505    #[case::december(2025, Month::December, "2025-12")]
1506    fn year_month_to_numeric_string_should_zero_pad(
1507        #[case] year: i32,
1508        #[case] month: Month,
1509        #[case] expected: &str,
1510    ) {
1511        let ym = YearMonth::new(year, month);
1512
1513        let actual = ym.to_numeric_string();
1514
1515        assert_eq!(actual, expected);
1516    }
1517}