1#![allow(
16 clippy::cast_possible_truncation,
17 clippy::cast_sign_loss,
18 reason = "the civil-calendar algorithm casts between i64 day-counts and the \
19 bounded month/day/ordinal fields (month 1..=12, day 1..=31, ordinal \
20 1..=366); every such cast is provably in range, exhaustively verified \
21 against chrono + a 12M-iteration fuzz over the full year span"
22)]
23
24const EPOCH_ISO_WEEKDAY_OFFSET: i64 = 3;
26
27#[must_use]
31pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
32 let m = i64::from(m);
33 let d = i64::from(d);
34 let y = if m <= 2 { y - 1 } else { y };
36 let era = if y >= 0 { y } else { y - 399 } / 400;
37 let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
41}
42
43#[must_use]
46pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
47 let z = z + 719_468;
48 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
49 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
52 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (if m <= 2 { y + 1 } else { y }, m, d)
57}
58
59#[must_use]
61pub fn is_leap(year: i64) -> bool {
62 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
63}
64
65#[must_use]
67pub fn days_in_month(year: i64, month: u32) -> u32 {
68 match month {
69 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
70 4 | 6 | 9 | 11 => 30,
71 2 => {
72 if is_leap(year) {
73 29
74 } else {
75 28
76 }
77 }
78 _ => 0,
79 }
80}
81
82#[must_use]
84pub fn days_in_year(year: i64) -> u32 {
85 if is_leap(year) { 366 } else { 365 }
86}
87
88#[must_use]
90pub fn ymd_to_days(y: i64, m: u32, d: u32) -> Option<i64> {
91 if !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
92 return None;
93 }
94 Some(days_from_civil(y, m, d))
95}
96
97#[must_use]
99pub fn from_ordinal(year: i64, ordinal: u32) -> Option<i64> {
100 if ordinal < 1 || ordinal > days_in_year(year) {
101 return None;
102 }
103 Some(days_from_civil(year, 1, 1) + i64::from(ordinal) - 1)
104}
105
106#[must_use]
108pub fn ordinal(z: i64) -> u32 {
109 let (y, _, _) = civil_from_days(z);
110 (z - days_from_civil(y, 1, 1) + 1) as u32
111}
112
113#[must_use]
115pub fn iso_weekday(z: i64) -> u32 {
116 ((z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7)) as u32 + 1
117}
118
119#[must_use]
121pub fn num_days_from_monday(z: i64) -> u32 {
122 (z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7) as u32
123}
124
125#[must_use]
127pub fn iso_weeks_in_year(iso_year: i64) -> u32 {
128 let jan1 = days_from_civil(iso_year, 1, 1);
132 let wd = iso_weekday(jan1); if wd == 4 || (wd == 3 && is_leap(iso_year)) {
134 53
135 } else {
136 52
137 }
138}
139
140#[must_use]
143pub fn iso_week(z: i64) -> (i64, u32) {
144 let (y, _, _) = civil_from_days(z);
145 let ord = i64::from(ordinal(z));
146 let wd = i64::from(iso_weekday(z));
147 let week = (ord - wd + 10) / 7;
149 if week < 1 {
150 (y - 1, iso_weeks_in_year(y - 1))
152 } else if week > i64::from(iso_weeks_in_year(y)) {
153 (y + 1, 1)
155 } else {
156 (y, week as u32)
157 }
158}
159
160#[must_use]
163pub fn from_iso_ywd(iso_year: i64, week: u32, weekday: u32) -> Option<i64> {
164 if !(1..=7).contains(&weekday) || week < 1 || week > iso_weeks_in_year(iso_year) {
165 return None;
166 }
167 let jan4 = days_from_civil(iso_year, 1, 4);
169 let week1_monday = jan4 - (i64::from(iso_weekday(jan4)) - 1);
170 Some(week1_monday + i64::from(week - 1) * 7 + i64::from(weekday - 1))
171}
172
173#[must_use]
177pub fn add_months(y: i64, m: u32, d: u32, months: i64) -> (i64, u32, u32) {
178 let total = y * 12 + i64::from(m - 1) + months;
179 let ny = total.div_euclid(12);
180 let nm = total.rem_euclid(12) as u32 + 1;
181 let nd = d.min(days_in_month(ny, nm));
182 (ny, nm, nd)
183}
184
185#[must_use]
188pub fn add_months_to_days(z: i64, months: i64) -> i64 {
189 let (y, m, d) = civil_from_days(z);
190 let (ny, nm, nd) = add_months(y, m, d, months);
191 days_from_civil(ny, nm, nd)
192}
193
194#[must_use]
196pub fn quarter_of_month(month: u32) -> u32 {
197 (month - 1) / 3 + 1
198}
199
200#[must_use]
206pub fn format_date(z: i64) -> String {
207 let (y, m, d) = civil_from_days(z);
208 format!("{}-{m:02}-{d:02}", format_year(y))
209}
210
211#[must_use]
213pub fn format_year(y: i64) -> String {
214 if (0..=9999).contains(&y) {
215 format!("{y:04}")
216 } else if y < 0 {
217 format!("-{:04}", -y)
219 } else {
220 format!("+{y}")
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use chrono::{Datelike, NaiveDate};
229
230 fn chrono_days(y: i32, m: u32, d: u32) -> i64 {
232 let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
233 (NaiveDate::from_ymd_opt(y, m, d).unwrap() - epoch).num_days()
234 }
235
236 #[test]
237 fn civil_roundtrip_and_matches_chrono_in_range() {
238 for &(y, m, d) in &[
240 (1970, 1, 1),
241 (2000, 2, 29),
242 (1984, 10, 11),
243 (1, 1, 1),
244 (0, 1, 1),
245 (-1, 12, 31),
246 (-44, 3, 15),
247 (9999, 12, 31),
248 (2017, 10, 29),
249 ] {
250 let z = days_from_civil(y, m, d);
251 assert_eq!(z, chrono_days(y as i32, m, d), "{y}-{m}-{d} vs chrono");
252 assert_eq!(civil_from_days(z), (y, m, d), "roundtrip {y}-{m}-{d}");
253 }
254 }
255
256 #[test]
257 fn extreme_years_roundtrip() {
258 for &(y, m, d) in &[(-999_999_999, 1, 1), (999_999_999, 12, 31)] {
259 let z = days_from_civil(y, m, d);
260 assert_eq!(civil_from_days(z), (y, m, d));
261 }
262 let lo = days_from_civil(-999_999_999, 1, 1);
265 let hi = days_from_civil(999_999_999, 12, 31);
266 assert!(hi - lo > 0);
267 }
268
269 #[test]
270 fn weekday_ordinal_match_chrono() {
271 for &(y, m, d) in &[(1970, 1, 1), (2025, 6, 30), (1984, 1, 1), (-1, 6, 15)] {
272 let z = days_from_civil(y, m, d);
273 let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
274 assert_eq!(
275 iso_weekday(z),
276 nd.weekday().number_from_monday(),
277 "wd {y}-{m}-{d}"
278 );
279 assert_eq!(num_days_from_monday(z), nd.weekday().num_days_from_monday());
280 assert_eq!(ordinal(z), nd.ordinal(), "ordinal {y}-{m}-{d}");
281 }
282 }
283
284 #[test]
285 fn iso_week_matches_chrono() {
286 for &(y, m, d) in &[
288 (1984, 1, 1), (2020, 12, 31), (2021, 1, 1), (2025, 6, 30),
292 (2016, 1, 1),
293 ] {
294 let z = days_from_civil(y, m, d);
295 let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
296 let iso = nd.iso_week();
297 assert_eq!(
298 iso_week(z),
299 (i64::from(iso.year()), iso.week()),
300 "iso_week {y}-{m}-{d}"
301 );
302 }
303 }
304
305 #[test]
306 fn from_iso_ywd_inverts_iso_week() {
307 for &(y, m, d) in &[(1984, 1, 1), (2020, 12, 31), (2025, 6, 30)] {
308 let z = days_from_civil(y, m, d);
309 let (iy, w) = iso_week(z);
310 let wd = iso_weekday(z);
311 assert_eq!(from_iso_ywd(iy, w, wd), Some(z), "ywd inverse {y}-{m}-{d}");
312 }
313 }
314
315 #[test]
316 fn add_months_clamps_day() {
317 assert_eq!(add_months(2025, 1, 31, 1), (2025, 2, 28)); assert_eq!(add_months(2024, 1, 31, 1), (2024, 2, 29)); assert_eq!(add_months(2025, 3, 15, -1), (2025, 2, 15));
320 assert_eq!(add_months(2025, 12, 10, 1), (2026, 1, 10)); assert_eq!(add_months(2025, 1, 10, -1), (2024, 12, 10)); }
323
324 #[test]
325 fn ymd_validation() {
326 assert_eq!(ymd_to_days(2025, 2, 30), None);
327 assert_eq!(ymd_to_days(2024, 2, 29), Some(days_from_civil(2024, 2, 29)));
328 assert_eq!(ymd_to_days(2025, 13, 1), None);
329 }
330
331 #[test]
332 fn format_date_matches_chrono_in_range_and_signs_extremes() {
333 for &(y, m, d) in &[(2025, 6, 30), (1, 1, 1), (985, 7, 4), (-1, 12, 31)] {
334 let z = days_from_civil(y, m, d);
335 let chrono = NaiveDate::from_ymd_opt(y as i32, m, d)
336 .unwrap()
337 .format("%Y-%m-%d")
338 .to_string();
339 assert_eq!(format_date(z), chrono, "format {y}-{m}-{d} vs chrono");
340 }
341 assert_eq!(
344 format_date(days_from_civil(-999_999_999, 1, 1)),
345 "-999999999-01-01"
346 );
347 assert_eq!(
348 format_date(days_from_civil(999_999_999, 12, 31)),
349 "+999999999-12-31"
350 );
351 assert_eq!(format_year(10000), "+10000");
352 assert_eq!(format_year(0), "0000");
353 }
354}