1use std::sync::OnceLock;
21
22use icu_calendar::{types, AnyCalendar, AnyCalendarKind, Date as IcuDate, Gregorian, Ref};
23use icu_locale_core::Locale as IcuLocale;
24
25use crate::calendar::{days_in_month, first_weekday_pub, Date};
26
27#[derive(Clone)]
29pub struct CalendarSystem {
30 kind: AnyCalendarKind,
31 calendar: AnyCalendar,
32 locale: String,
35}
36
37impl CalendarSystem {
38 pub(crate) fn for_locale(tag: &str) -> Option<Self> {
40 let locale = tag.parse::<IcuLocale>().ok()?;
41 let kind = AnyCalendarKind::new((&locale).into());
42 Some(Self {
43 kind,
44 calendar: AnyCalendar::new(kind),
45 locale: tag.to_owned(),
46 })
47 }
48
49 pub fn locale(&self) -> &str {
51 &self.locale
52 }
53
54 pub fn is_gregorian(&self) -> bool {
57 self.kind == AnyCalendarKind::Gregorian
58 }
59
60 fn icu(&self, date: Date) -> Option<IcuDate<Ref<'_, AnyCalendar>>> {
61 let month = u8::try_from(date.month).ok()?;
62 let day = u8::try_from(date.day).ok()?;
63 Some(
64 IcuDate::try_new_gregorian(date.year, month, day)
65 .ok()?
66 .to_calendar(Ref(&self.calendar)),
67 )
68 }
69
70 pub fn from_gregorian(&self, date: Date) -> (i32, u32, u32) {
72 if self.is_gregorian() {
73 return (date.year, date.month, date.day);
74 }
75 self.icu(date)
76 .map_or((date.year, date.month, date.day), |d| {
77 (
78 d.year().extended_year(),
79 u32::from(d.month().ordinal),
80 u32::from(d.day_of_month().0),
81 )
82 })
83 }
84
85 pub fn to_gregorian(&self, year: i32, month: u32, day: u32) -> Option<Date> {
90 if self.is_gregorian() {
91 return ((1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month))
92 .then(|| Date::new(year, month, day));
93 }
94 let date = self.date_in(year, month, day)?.to_calendar(Gregorian);
95 Some(Date::new(
96 date.year().extended_year(),
97 u32::from(date.month().ordinal),
98 u32::from(date.day_of_month().0),
99 ))
100 }
101
102 pub fn days_in_month(&self, year: i32, month: u32) -> u32 {
104 if self.is_gregorian() {
105 return days_in_month(year, month);
106 }
107 self.date_in(year, month, 1).map_or_else(
108 || days_in_month(year, month),
109 |d| u32::from(d.days_in_month()),
110 )
111 }
112
113 pub fn months_in_year(&self, year: i32) -> u32 {
115 if self.is_gregorian() {
116 return 12;
117 }
118 self.date_in(year, 1, 1)
119 .map_or(12, |d| u32::from(d.months_in_year()))
120 }
121
122 pub fn first_weekday(&self, year: i32, month: u32) -> usize {
125 if self.is_gregorian() {
126 return first_weekday_pub(year, month);
127 }
128 self.date_in(year, month, 1).map_or_else(
129 || first_weekday_pub(year, month),
130 |d| (d.weekday() as usize + 6) % 7,
131 )
132 }
133
134 pub fn add_months(&self, year: i32, month: u32, delta: i32) -> (i32, u32) {
140 if self.is_gregorian() {
141 return crate::calendar::add_months(year, month, delta);
142 }
143 let (mut year, mut month) = (year, month);
144 for _ in 0..delta.abs() {
145 if delta > 0 {
146 if month >= self.months_in_year(year) {
147 year += 1;
148 month = 1;
149 } else {
150 month += 1;
151 }
152 } else if month <= 1 {
153 year -= 1;
154 month = self.months_in_year(year);
155 } else {
156 month -= 1;
157 }
158 }
159 (year, month)
160 }
161
162 pub(crate) fn add_years(&self, date: Date, years: i32) -> Date {
164 if self.is_gregorian() {
165 let year = date.year + years;
166 return Date::new(
167 year,
168 date.month,
169 date.day.min(days_in_month(year, date.month)),
170 );
171 }
172 let Some(mut value) = self.icu(date) else {
173 return date;
174 };
175 if value
176 .try_add_with_options(
177 types::DateDuration {
178 years: years.unsigned_abs(),
179 is_negative: years < 0,
180 ..Default::default()
181 },
182 Default::default(),
183 )
184 .is_err()
185 {
186 return date;
187 }
188 let value = value.to_calendar(Gregorian);
189 Date::new(
190 value.year().extended_year(),
191 u32::from(value.month().ordinal),
192 u32::from(value.day_of_month().0),
193 )
194 }
195
196 fn date_in(&self, year: i32, month: u32, day: u32) -> Option<IcuDate<Ref<'_, AnyCalendar>>> {
197 let mut fields = types::DateFields::default();
198 fields.extended_year = Some(year);
199 fields.ordinal_month = Some(u8::try_from(month).ok()?);
200 fields.day = Some(u8::try_from(day).ok()?);
201 IcuDate::try_from_fields(fields, Default::default(), Ref(&self.calendar)).ok()
202 }
203}
204
205pub fn system() -> &'static CalendarSystem {
210 static SYSTEM: OnceLock<CalendarSystem> = OnceLock::new();
211 SYSTEM.get_or_init(|| {
212 crate::date_constraints::system_locale_tags()
213 .iter()
214 .find_map(|tag| CalendarSystem::for_locale(tag))
215 .unwrap_or_else(|| {
216 CalendarSystem::for_locale("en-US").expect("en-US is a valid locale")
217 })
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn year_steps_preserve_calendar_month_codes_and_constrain_leap_days() {
227 let gregorian = CalendarSystem::for_locale("en-US").unwrap();
228 assert_eq!(
229 gregorian.add_years(Date::new(2024, 2, 29), 1),
230 Date::new(2025, 2, 28)
231 );
232 let hebrew = CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
233 assert_eq!(
234 hebrew.add_years(Date::new(2024, 3, 25), 1),
235 Date::new(2025, 3, 15)
236 );
237 assert_eq!(
238 hebrew.add_years(Date::new(2025, 3, 15), -1),
239 Date::new(2024, 3, 25)
240 );
241 }
242
243 #[test]
244 fn leap_month_ordinals_round_trip_without_becoming_month_codes() {
245 let system = CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
246 for date in [
247 Date::new(2024, 2, 20),
248 Date::new(2024, 3, 25),
249 Date::new(2024, 4, 25),
250 Date::new(2024, 9, 25),
251 ] {
252 let (year, month, day) = system.from_gregorian(date);
253 assert_eq!(system.to_gregorian(year, month, day), Some(date));
254 }
255 assert_eq!(system.days_in_month(5784, 6), 30);
256 assert_eq!(system.days_in_month(5784, 7), 29);
257 }
258
259 fn indian() -> CalendarSystem {
260 CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap()
261 }
262
263 #[test]
264 fn a_locale_without_an_extension_is_gregorian() {
265 assert!(CalendarSystem::for_locale("en-US").unwrap().is_gregorian());
266 assert!(CalendarSystem::for_locale("de-DE").unwrap().is_gregorian());
267 assert!(!indian().is_gregorian());
268 assert!(!CalendarSystem::for_locale("th-TH-u-ca-buddhist")
269 .unwrap()
270 .is_gregorian());
271 }
272
273 #[test]
274 fn the_gregorian_path_matches_the_plain_arithmetic() {
275 let system = CalendarSystem::for_locale("en-US").unwrap();
276 for (year, month) in [(2026, 1), (2024, 2), (2025, 2), (2026, 4), (2026, 12)] {
277 assert_eq!(
278 system.days_in_month(year, month),
279 days_in_month(year, month)
280 );
281 assert_eq!(
282 system.first_weekday(year, month),
283 first_weekday_pub(year, month)
284 );
285 assert_eq!(system.months_in_year(year), 12);
286 }
287 let date = Date::new(2026, 1, 15);
288 assert_eq!(system.from_gregorian(date), (2026, 1, 15));
289 assert_eq!(system.to_gregorian(2026, 1, 15), Some(date));
290 }
291
292 #[test]
293 fn a_non_gregorian_system_reports_its_own_year_and_month() {
294 let system = indian();
297 assert_eq!(
298 system.from_gregorian(Date::new(2026, 1, 15)),
299 (1947, 10, 25)
300 );
301 assert_eq!(system.days_in_month(1947, 10), 30);
302 assert_eq!(system.months_in_year(1947), 12);
303 }
304
305 #[test]
306 fn conversion_round_trips_through_the_other_system() {
307 let system = indian();
308 for date in [
309 Date::new(2026, 1, 15),
310 Date::new(2026, 3, 22),
311 Date::new(2024, 2, 29),
312 Date::new(1999, 12, 31),
313 ] {
314 let (year, month, day) = system.from_gregorian(date);
315 assert_eq!(
316 system.to_gregorian(year, month, day),
317 Some(date),
318 "{date:?} must survive the trip through the Indian calendar"
319 );
320 }
321 }
322
323 #[test]
324 fn an_impossible_day_reports_nothing_rather_than_clamping() {
325 let gregorian = CalendarSystem::for_locale("en-US").unwrap();
326 assert_eq!(
327 gregorian.to_gregorian(2025, 2, 29),
328 None,
329 "2025 is not a leap year"
330 );
331 assert_eq!(
332 gregorian.to_gregorian(2026, 13, 1),
333 None,
334 "there is no 13th month"
335 );
336 assert_eq!(
337 gregorian.to_gregorian(2026, 4, 31),
338 None,
339 "April has 30 days"
340 );
341 assert_eq!(indian().to_gregorian(1947, 10, 31), None);
344 }
345
346 #[test]
347 fn stepping_months_wraps_each_system_own_year() {
348 let gregorian = CalendarSystem::for_locale("en-US").unwrap();
349 assert_eq!(gregorian.add_months(2026, 12, 1), (2027, 1));
350 assert_eq!(gregorian.add_months(2026, 1, -1), (2025, 12));
351 assert_eq!(gregorian.add_months(2026, 8, 5), (2027, 1));
352
353 let system = indian();
356 let months = system.months_in_year(1947);
357 assert_eq!(system.add_months(1947, months, 1), (1948, 1));
358 assert_eq!(system.add_months(1948, 1, -1), (1947, months));
359 }
360
361 #[test]
362 fn stepping_a_month_moves_a_real_day_by_a_real_month() {
363 let system = indian();
366 let (year, month) = system.add_months(1947, 10, 1);
367 let this = system.to_gregorian(1947, 10, 1).unwrap();
368 let next = system.to_gregorian(year, month, 1).unwrap();
369 assert!(
370 crate::calendar::days_from_civil(&next) > crate::calendar::days_from_civil(&this),
371 "{next:?} must follow {this:?}"
372 );
373 }
374
375 #[test]
376 fn the_first_weekday_is_monday_indexed_in_both_systems() {
377 let system = indian();
380 let first = system.to_gregorian(1947, 10, 1).unwrap();
381 assert_eq!(
382 system.first_weekday(1947, 10),
383 crate::calendar::weekday_index(first)
384 );
385 }
386}