use serde::{Deserialize, Serialize};
use crate::error::{Result, SankhyaError};
pub const GREGORIAN_EPOCH_JDN: f64 = 1_721_425.5;
const GREGORIAN_MONTH_DAYS: [u8; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GregorianMonth {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December,
}
const GREGORIAN_MONTHS: [GregorianMonth; 12] = [
GregorianMonth::January,
GregorianMonth::February,
GregorianMonth::March,
GregorianMonth::April,
GregorianMonth::May,
GregorianMonth::June,
GregorianMonth::July,
GregorianMonth::August,
GregorianMonth::September,
GregorianMonth::October,
GregorianMonth::November,
GregorianMonth::December,
];
impl core::fmt::Display for GregorianMonth {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::January => "January",
Self::February => "February",
Self::March => "March",
Self::April => "April",
Self::May => "May",
Self::June => "June",
Self::July => "July",
Self::August => "August",
Self::September => "September",
Self::October => "October",
Self::November => "November",
Self::December => "December",
};
write!(f, "{name}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GregorianDate {
pub year: i64,
pub month: GregorianMonth,
pub day: u8,
}
impl core::fmt::Display for GregorianDate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
if self.year >= 1 {
write!(f, "{} {} {} CE", self.day, self.month, self.year)
} else {
write!(f, "{} {} {} BCE", self.day, self.month, 1 - self.year)
}
}
}
#[must_use]
#[inline]
pub fn gregorian_is_leap(year: i64) -> bool {
(year % 4 == 0) && (year % 100 != 0 || year % 400 == 0)
}
#[must_use]
#[inline]
pub fn gregorian_year_days(year: i64) -> u16 {
if gregorian_is_leap(year) { 366 } else { 365 }
}
#[must_use]
pub fn jdn_to_gregorian(jdn: f64) -> GregorianDate {
tracing::trace!(jdn, "JDN to Gregorian");
let j = (jdn + 0.5).floor() as i64;
let days = j - 1_721_120;
let n400 = days.div_euclid(146_097);
let d400 = days.rem_euclid(146_097);
let n100 = (d400 / 36_524).min(3);
let d100 = d400 - n100 * 36_524;
let n4 = d100 / 1_461;
let d4 = d100 - n4 * 1_461;
let n1 = (d4 / 365).min(3);
let day_of_year = d4 - n1 * 365;
let mp = (5 * day_of_year + 2) / 153;
let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u8;
let month_idx = if mp < 10 {
(mp + 2) as usize
} else {
(mp - 10) as usize
};
let mut year = 400 * n400 + 100 * n100 + 4 * n4 + n1;
if mp >= 10 {
year += 1;
}
GregorianDate {
year,
month: GREGORIAN_MONTHS[month_idx],
day,
}
}
#[must_use = "returns the JDN or an error"]
pub fn gregorian_to_jdn(date: &GregorianDate) -> Result<f64> {
tracing::trace!(year = date.year, ?date.month, day = date.day, "Gregorian to JDN");
let month_idx = GREGORIAN_MONTHS
.iter()
.position(|&m| m == date.month)
.unwrap_or(0);
let max_day = if month_idx == 1 && gregorian_is_leap(date.year) {
29
} else {
GREGORIAN_MONTH_DAYS[month_idx]
};
if date.day == 0 || date.day > max_day {
return Err(SankhyaError::InvalidDate(format!(
"day {} out of range for {} {} (max {max_day})",
date.day, date.month, date.year
)));
}
let (y, mp) = if month_idx < 2 {
(date.year - 1, (month_idx + 10) as i64)
} else {
(date.year, (month_idx - 2) as i64)
};
let days = 365 * y + y.div_euclid(4) - y.div_euclid(100)
+ y.div_euclid(400)
+ (153 * mp + 2) / 5
+ i64::from(date.day)
- 1;
Ok((days + 1_721_120) as f64 - 0.5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_roundtrip() {
let date = jdn_to_gregorian(GREGORIAN_EPOCH_JDN);
assert_eq!(date.year, 1);
assert_eq!(date.month, GregorianMonth::January);
assert_eq!(date.day, 1);
let jdn = gregorian_to_jdn(&date).unwrap();
assert!((jdn - GREGORIAN_EPOCH_JDN).abs() < f64::EPSILON);
}
#[test]
fn known_date_j2000() {
let date = jdn_to_gregorian(2_451_545.0);
assert_eq!(date.year, 2000);
assert_eq!(date.month, GregorianMonth::January);
assert_eq!(date.day, 1);
}
#[test]
fn known_date_mayan_end() {
let date = jdn_to_gregorian(2_456_282.5);
assert_eq!(date.year, 2012);
assert_eq!(date.month, GregorianMonth::December);
assert_eq!(date.day, 21);
}
#[test]
fn known_date_gregorian_reform() {
let date = jdn_to_gregorian(2_299_160.5);
assert_eq!(date.year, 1582);
assert_eq!(date.month, GregorianMonth::October);
assert_eq!(date.day, 15);
}
#[test]
fn leap_year_rules() {
assert!(gregorian_is_leap(2000)); assert!(!gregorian_is_leap(1900)); assert!(gregorian_is_leap(2024)); assert!(gregorian_is_leap(1600)); assert!(!gregorian_is_leap(2023)); assert!(gregorian_is_leap(0)); assert!(!gregorian_is_leap(-1)); }
#[test]
fn year_days() {
assert_eq!(gregorian_year_days(2024), 366);
assert_eq!(gregorian_year_days(2023), 365);
assert_eq!(gregorian_year_days(2000), 366);
assert_eq!(gregorian_year_days(1900), 365);
}
#[test]
fn roundtrip_modern_dates() {
for jdn in [
2_451_544.5, 2_456_282.5, 2_460_676.5, 2_299_160.5, ] {
let date = jdn_to_gregorian(jdn);
let back = gregorian_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: got {back}"
);
}
}
#[test]
fn roundtrip_ancient_dates() {
for jdn_int in (0..3_000_000).step_by(10_000) {
let jdn = jdn_int as f64 + 0.5;
let date = jdn_to_gregorian(jdn);
let back = gregorian_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: year={}, month={:?}, day={}, back={back}",
date.year,
date.month,
date.day
);
}
}
#[test]
fn roundtrip_sequential_days() {
for offset in 0..1000 {
let jdn = 2_451_544.5 + f64::from(offset);
let date = jdn_to_gregorian(jdn);
let back = gregorian_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}"
);
}
}
#[test]
fn leap_feb_29() {
let date = GregorianDate {
year: 2024,
month: GregorianMonth::February,
day: 29,
};
assert!(gregorian_to_jdn(&date).is_ok());
}
#[test]
fn non_leap_feb_29_errors() {
let date = GregorianDate {
year: 2023,
month: GregorianMonth::February,
day: 29,
};
assert!(gregorian_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_zero() {
let date = GregorianDate {
year: 2000,
month: GregorianMonth::January,
day: 0,
};
assert!(gregorian_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_32() {
let date = GregorianDate {
year: 2000,
month: GregorianMonth::January,
day: 32,
};
assert!(gregorian_to_jdn(&date).is_err());
}
#[test]
fn display_ce() {
let date = GregorianDate {
year: 2025,
month: GregorianMonth::April,
day: 1,
};
assert_eq!(date.to_string(), "1 April 2025 CE");
}
#[test]
fn display_bce() {
let date = GregorianDate {
year: -43,
month: GregorianMonth::March,
day: 15,
};
assert_eq!(date.to_string(), "15 March 44 BCE");
}
#[test]
fn serde_roundtrip() {
let date = jdn_to_gregorian(2_451_545.0);
let json = serde_json::to_string(&date).unwrap();
let back: GregorianDate = serde_json::from_str(&json).unwrap();
assert_eq!(date, back);
}
}