1use chrono::{Datelike, Duration, NaiveDate, Weekday as ChronoWeekday};
4
5pub use chrono::Weekday;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum WeekendRoll {
10 None,
12 NearestWeekday,
14}
15
16#[derive(Clone, Debug)]
18pub enum HolidayRule {
19 Fixed {
21 month: u32,
22 day: u32,
23 roll: WeekendRoll,
24 since_year: Option<i32>,
25 },
26 NthWeekday {
29 month: u32,
30 weekday: Weekday,
31 n: i32,
32 since_year: Option<i32>,
33 },
34 EasterOffset {
36 offset_days: i32,
37 since_year: Option<i32>,
38 },
39 Tabulated { table: &'static [(i32, u32, u32)] },
41}
42
43impl HolidayRule {
44 pub fn observed_in(&self, year: i32) -> Option<NaiveDate> {
46 match self {
47 HolidayRule::Fixed {
48 month,
49 day,
50 roll,
51 since_year,
52 } => {
53 if let Some(y) = since_year {
54 if year < *y {
55 return None;
56 }
57 }
58 let raw = NaiveDate::from_ymd_opt(year, *month, *day)?;
59 Some(apply_roll(raw, *roll))
60 }
61 HolidayRule::NthWeekday {
62 month,
63 weekday,
64 n,
65 since_year,
66 } => {
67 if let Some(y) = since_year {
68 if year < *y {
69 return None;
70 }
71 }
72 nth_weekday_of_month(year, *month, *weekday, *n)
73 }
74 HolidayRule::EasterOffset {
75 offset_days,
76 since_year,
77 } => {
78 if let Some(y) = since_year {
79 if year < *y {
80 return None;
81 }
82 }
83 let easter = easter_sunday(year)?;
84 Some(easter + Duration::days(*offset_days as i64))
85 }
86 HolidayRule::Tabulated { table } => table
87 .iter()
88 .find(|(y, _, _)| *y == year)
89 .and_then(|(_, m, d)| NaiveDate::from_ymd_opt(year, *m, *d)),
90 }
91 }
92
93 pub fn dates_in(&self, year: i32) -> Vec<NaiveDate> {
97 match self {
98 HolidayRule::Tabulated { table } => table
99 .iter()
100 .filter(|(y, _, _)| *y == year)
101 .filter_map(|(_, m, d)| NaiveDate::from_ymd_opt(year, *m, *d))
102 .collect(),
103 _ => self.observed_in(year).into_iter().collect(),
104 }
105 }
106}
107
108fn apply_roll(d: NaiveDate, roll: WeekendRoll) -> NaiveDate {
109 match roll {
110 WeekendRoll::None => d,
111 WeekendRoll::NearestWeekday => match d.weekday() {
112 ChronoWeekday::Sat => d - Duration::days(1),
113 ChronoWeekday::Sun => d + Duration::days(1),
114 _ => d,
115 },
116 }
117}
118
119pub fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: i32) -> Option<NaiveDate> {
122 if n == 0 {
123 return None;
124 }
125 if n > 0 {
126 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
127 let offset = (weekday.num_days_from_monday() as i64
128 - first.weekday().num_days_from_monday() as i64)
129 .rem_euclid(7)
130 + 7 * (n as i64 - 1);
131 let candidate = first + Duration::days(offset);
132 if candidate.month() == month {
133 Some(candidate)
134 } else {
135 None
136 }
137 } else {
138 let last_of_month = match month {
140 12 => NaiveDate::from_ymd_opt(year + 1, 1, 1)? - Duration::days(1),
141 _ => NaiveDate::from_ymd_opt(year, month + 1, 1)? - Duration::days(1),
142 };
143 let back = (last_of_month.weekday().num_days_from_monday() as i64
144 - weekday.num_days_from_monday() as i64)
145 .rem_euclid(7);
146 let last_of_kind = last_of_month - Duration::days(back);
147 let candidate = last_of_kind - Duration::days(((-n - 1) as i64) * 7);
148 if candidate.month() == month {
149 Some(candidate)
150 } else {
151 None
152 }
153 }
154}
155
156pub fn easter_sunday(year: i32) -> Option<NaiveDate> {
158 let a = year % 19;
159 let b = year / 100;
160 let c = year % 100;
161 let d = b / 4;
162 let e = b % 4;
163 let f = (b + 8) / 25;
164 let g = (b - f + 1) / 3;
165 let h = (19 * a + b - d - g + 15) % 30;
166 let i = c / 4;
167 let k = c % 4;
168 let l = (32 + 2 * e + 2 * i - h - k) % 7;
169 let m = (a + 11 * h + 22 * l) / 451;
170 let month = (h + l - 7 * m + 114) / 31;
171 let day = ((h + l - 7 * m + 114) % 31) + 1;
172 NaiveDate::from_ymd_opt(year, month as u32, day as u32)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn easter_known_dates() {
181 assert_eq!(
182 easter_sunday(2024).unwrap(),
183 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
184 );
185 assert_eq!(
186 easter_sunday(2025).unwrap(),
187 NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
188 );
189 assert_eq!(
190 easter_sunday(2000).unwrap(),
191 NaiveDate::from_ymd_opt(2000, 4, 23).unwrap()
192 );
193 }
194
195 #[test]
196 fn nth_weekday_mlk_day() {
197 let d = nth_weekday_of_month(2024, 1, Weekday::Mon, 3).unwrap();
198 assert_eq!(d, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
199 }
200
201 #[test]
202 fn nth_weekday_last_memorial_day() {
203 let d = nth_weekday_of_month(2024, 5, Weekday::Mon, -1).unwrap();
204 assert_eq!(d, NaiveDate::from_ymd_opt(2024, 5, 27).unwrap());
205 }
206
207 #[test]
208 fn nth_weekday_thanksgiving_2024() {
209 let d = nth_weekday_of_month(2024, 11, Weekday::Thu, 4).unwrap();
211 assert_eq!(d, NaiveDate::from_ymd_opt(2024, 11, 28).unwrap());
212 }
213
214 #[test]
215 fn weekend_roll_christmas_2022() {
216 let r = HolidayRule::Fixed {
217 month: 12,
218 day: 25,
219 roll: WeekendRoll::NearestWeekday,
220 since_year: None,
221 };
222 assert_eq!(
223 r.observed_in(2022).unwrap(),
224 NaiveDate::from_ymd_opt(2022, 12, 26).unwrap()
225 );
226 }
227
228 #[test]
229 fn juneteenth_since_2021() {
230 let r = HolidayRule::Fixed {
231 month: 6,
232 day: 19,
233 roll: WeekendRoll::NearestWeekday,
234 since_year: Some(2021),
235 };
236 assert!(r.observed_in(2020).is_none());
237 assert!(r.observed_in(2021).is_some());
238 }
239}