#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the civil-calendar algorithm casts between i64 day-counts and the \
bounded month/day/ordinal fields (month 1..=12, day 1..=31, ordinal \
1..=366); every such cast is provably in range, exhaustively verified \
against chrono + a 12M-iteration fuzz over the full year span"
)]
const EPOCH_ISO_WEEKDAY_OFFSET: i64 = 3;
#[must_use]
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let m = i64::from(m);
let d = i64::from(d);
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
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
}
#[must_use]
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
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)
}
#[must_use]
pub fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[must_use]
pub fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap(year) {
29
} else {
28
}
}
_ => 0,
}
}
#[must_use]
pub fn days_in_year(year: i64) -> u32 {
if is_leap(year) { 366 } else { 365 }
}
#[must_use]
pub fn ymd_to_days(y: i64, m: u32, d: u32) -> Option<i64> {
if !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
return None;
}
Some(days_from_civil(y, m, d))
}
#[must_use]
pub fn from_ordinal(year: i64, ordinal: u32) -> Option<i64> {
if ordinal < 1 || ordinal > days_in_year(year) {
return None;
}
Some(days_from_civil(year, 1, 1) + i64::from(ordinal) - 1)
}
#[must_use]
pub fn ordinal(z: i64) -> u32 {
let (y, _, _) = civil_from_days(z);
(z - days_from_civil(y, 1, 1) + 1) as u32
}
#[must_use]
pub fn iso_weekday(z: i64) -> u32 {
((z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7)) as u32 + 1
}
#[must_use]
pub fn num_days_from_monday(z: i64) -> u32 {
(z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7) as u32
}
#[must_use]
pub fn iso_weeks_in_year(iso_year: i64) -> u32 {
let jan1 = days_from_civil(iso_year, 1, 1);
let wd = iso_weekday(jan1); if wd == 4 || (wd == 3 && is_leap(iso_year)) {
53
} else {
52
}
}
#[must_use]
pub fn iso_week(z: i64) -> (i64, u32) {
let (y, _, _) = civil_from_days(z);
let ord = i64::from(ordinal(z));
let wd = i64::from(iso_weekday(z));
let week = (ord - wd + 10) / 7;
if week < 1 {
(y - 1, iso_weeks_in_year(y - 1))
} else if week > i64::from(iso_weeks_in_year(y)) {
(y + 1, 1)
} else {
(y, week as u32)
}
}
#[must_use]
pub fn from_iso_ywd(iso_year: i64, week: u32, weekday: u32) -> Option<i64> {
if !(1..=7).contains(&weekday) || week < 1 || week > iso_weeks_in_year(iso_year) {
return None;
}
let jan4 = days_from_civil(iso_year, 1, 4);
let week1_monday = jan4 - (i64::from(iso_weekday(jan4)) - 1);
Some(week1_monday + i64::from(week - 1) * 7 + i64::from(weekday - 1))
}
#[must_use]
pub fn add_months(y: i64, m: u32, d: u32, months: i64) -> (i64, u32, u32) {
let total = y * 12 + i64::from(m - 1) + months;
let ny = total.div_euclid(12);
let nm = total.rem_euclid(12) as u32 + 1;
let nd = d.min(days_in_month(ny, nm));
(ny, nm, nd)
}
#[must_use]
pub fn add_months_to_days(z: i64, months: i64) -> i64 {
let (y, m, d) = civil_from_days(z);
let (ny, nm, nd) = add_months(y, m, d, months);
days_from_civil(ny, nm, nd)
}
#[must_use]
pub fn quarter_of_month(month: u32) -> u32 {
(month - 1) / 3 + 1
}
#[must_use]
pub fn format_date(z: i64) -> String {
let (y, m, d) = civil_from_days(z);
format!("{}-{m:02}-{d:02}", format_year(y))
}
#[must_use]
pub fn format_year(y: i64) -> String {
if (0..=9999).contains(&y) {
format!("{y:04}")
} else if y < 0 {
format!("-{:04}", -y)
} else {
format!("+{y}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, NaiveDate};
fn chrono_days(y: i32, m: u32, d: u32) -> i64 {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
(NaiveDate::from_ymd_opt(y, m, d).unwrap() - epoch).num_days()
}
#[test]
fn civil_roundtrip_and_matches_chrono_in_range() {
for &(y, m, d) in &[
(1970, 1, 1),
(2000, 2, 29),
(1984, 10, 11),
(1, 1, 1),
(0, 1, 1),
(-1, 12, 31),
(-44, 3, 15),
(9999, 12, 31),
(2017, 10, 29),
] {
let z = days_from_civil(y, m, d);
assert_eq!(z, chrono_days(y as i32, m, d), "{y}-{m}-{d} vs chrono");
assert_eq!(civil_from_days(z), (y, m, d), "roundtrip {y}-{m}-{d}");
}
}
#[test]
fn extreme_years_roundtrip() {
for &(y, m, d) in &[(-999_999_999, 1, 1), (999_999_999, 12, 31)] {
let z = days_from_civil(y, m, d);
assert_eq!(civil_from_days(z), (y, m, d));
}
let lo = days_from_civil(-999_999_999, 1, 1);
let hi = days_from_civil(999_999_999, 12, 31);
assert!(hi - lo > 0);
}
#[test]
fn weekday_ordinal_match_chrono() {
for &(y, m, d) in &[(1970, 1, 1), (2025, 6, 30), (1984, 1, 1), (-1, 6, 15)] {
let z = days_from_civil(y, m, d);
let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
assert_eq!(
iso_weekday(z),
nd.weekday().number_from_monday(),
"wd {y}-{m}-{d}"
);
assert_eq!(num_days_from_monday(z), nd.weekday().num_days_from_monday());
assert_eq!(ordinal(z), nd.ordinal(), "ordinal {y}-{m}-{d}");
}
}
#[test]
fn iso_week_matches_chrono() {
for &(y, m, d) in &[
(1984, 1, 1), (2020, 12, 31), (2021, 1, 1), (2025, 6, 30),
(2016, 1, 1),
] {
let z = days_from_civil(y, m, d);
let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
let iso = nd.iso_week();
assert_eq!(
iso_week(z),
(i64::from(iso.year()), iso.week()),
"iso_week {y}-{m}-{d}"
);
}
}
#[test]
fn from_iso_ywd_inverts_iso_week() {
for &(y, m, d) in &[(1984, 1, 1), (2020, 12, 31), (2025, 6, 30)] {
let z = days_from_civil(y, m, d);
let (iy, w) = iso_week(z);
let wd = iso_weekday(z);
assert_eq!(from_iso_ywd(iy, w, wd), Some(z), "ywd inverse {y}-{m}-{d}");
}
}
#[test]
fn add_months_clamps_day() {
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));
assert_eq!(add_months(2025, 12, 10, 1), (2026, 1, 10)); assert_eq!(add_months(2025, 1, 10, -1), (2024, 12, 10)); }
#[test]
fn ymd_validation() {
assert_eq!(ymd_to_days(2025, 2, 30), None);
assert_eq!(ymd_to_days(2024, 2, 29), Some(days_from_civil(2024, 2, 29)));
assert_eq!(ymd_to_days(2025, 13, 1), None);
}
#[test]
fn format_date_matches_chrono_in_range_and_signs_extremes() {
for &(y, m, d) in &[(2025, 6, 30), (1, 1, 1), (985, 7, 4), (-1, 12, 31)] {
let z = days_from_civil(y, m, d);
let chrono = NaiveDate::from_ymd_opt(y as i32, m, d)
.unwrap()
.format("%Y-%m-%d")
.to_string();
assert_eq!(format_date(z), chrono, "format {y}-{m}-{d} vs chrono");
}
assert_eq!(
format_date(days_from_civil(-999_999_999, 1, 1)),
"-999999999-01-01"
);
assert_eq!(
format_date(days_from_civil(999_999_999, 12, 31)),
"+999999999-12-31"
);
assert_eq!(format_year(10000), "+10000");
assert_eq!(format_year(0), "0000");
}
}