use serde::{Deserialize, Serialize};
use crate::error::{Result, SankhyaError};
pub const HEBREW_EPOCH_JDN: f64 = 347_996.5;
const PARTS_PER_HOUR: i64 = 1080;
const PARTS_PER_DAY: i64 = 24 * PARTS_PER_HOUR;
const MONTH_PARTS: i64 = 29 * PARTS_PER_DAY + 12 * PARTS_PER_HOUR + 793;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum HebrewMonth {
Tishrei,
Cheshvan,
Kislev,
Tevet,
Shevat,
Adar,
AdarI,
AdarII,
Nisan,
Iyar,
Sivan,
Tammuz,
Av,
Elul,
}
impl core::fmt::Display for HebrewMonth {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Tishrei => "Tishrei",
Self::Cheshvan => "Cheshvan",
Self::Kislev => "Kislev",
Self::Tevet => "Tevet",
Self::Shevat => "Shevat",
Self::Adar => "Adar",
Self::AdarI => "Adar I",
Self::AdarII => "Adar II",
Self::Nisan => "Nisan",
Self::Iyar => "Iyar",
Self::Sivan => "Sivan",
Self::Tammuz => "Tammuz",
Self::Av => "Av",
Self::Elul => "Elul",
};
write!(f, "{name}")
}
}
const COMMON_MONTHS: [HebrewMonth; 12] = [
HebrewMonth::Tishrei,
HebrewMonth::Cheshvan,
HebrewMonth::Kislev,
HebrewMonth::Tevet,
HebrewMonth::Shevat,
HebrewMonth::Adar,
HebrewMonth::Nisan,
HebrewMonth::Iyar,
HebrewMonth::Sivan,
HebrewMonth::Tammuz,
HebrewMonth::Av,
HebrewMonth::Elul,
];
const LEAP_MONTHS: [HebrewMonth; 13] = [
HebrewMonth::Tishrei,
HebrewMonth::Cheshvan,
HebrewMonth::Kislev,
HebrewMonth::Tevet,
HebrewMonth::Shevat,
HebrewMonth::AdarI,
HebrewMonth::AdarII,
HebrewMonth::Nisan,
HebrewMonth::Iyar,
HebrewMonth::Sivan,
HebrewMonth::Tammuz,
HebrewMonth::Av,
HebrewMonth::Elul,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HebrewDate {
pub year: i64,
pub month: HebrewMonth,
pub day: u8,
}
impl core::fmt::Display for HebrewDate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{} {} {} AM", self.day, self.month, self.year)
}
}
#[must_use]
#[inline]
pub fn hebrew_is_leap(year: i64) -> bool {
let r = ((7 * year) + 1).rem_euclid(19);
r < 7
}
fn molad_tishrei(year: i64) -> i64 {
let months_elapsed = months_before_year(year);
let molad_epoch = PARTS_PER_DAY + 5 * PARTS_PER_HOUR + 204;
molad_epoch + months_elapsed * MONTH_PARTS
}
fn months_before_year(year: i64) -> i64 {
let y = year - 1;
let cycles = y.div_euclid(19);
let remainder = y.rem_euclid(19);
let mut months = cycles * 235;
for i in 0..remainder {
let yr = i + 1;
months += if hebrew_is_leap_in_cycle(yr) { 13 } else { 12 };
}
months
}
fn hebrew_is_leap_in_cycle(pos: i64) -> bool {
matches!(pos, 3 | 6 | 8 | 11 | 14 | 17 | 19)
}
fn rosh_hashana_jdn(year: i64) -> i64 {
let molad = molad_tishrei(year);
let day = molad.div_euclid(PARTS_PER_DAY);
let parts_in_day = molad.rem_euclid(PARTS_PER_DAY);
let dow = day.rem_euclid(7);
let mut jdn = day + 347_996;
let mut postpone = 0;
if dow == 0 || dow == 3 || dow == 5 {
postpone = 1;
}
if parts_in_day >= 18 * PARTS_PER_HOUR {
postpone = 1;
let new_dow = (dow + 1).rem_euclid(7);
if new_dow == 0 || new_dow == 3 || new_dow == 5 {
postpone = 2;
}
}
if !hebrew_is_leap(year) && dow == 2 && parts_in_day >= 9 * PARTS_PER_HOUR + 204 {
postpone = 2; }
if hebrew_is_leap(year - 1) && dow == 1 && parts_in_day >= 15 * PARTS_PER_HOUR + 589 {
postpone = 1;
}
jdn += postpone;
jdn
}
#[must_use]
pub fn hebrew_year_days(year: i64) -> u16 {
(rosh_hashana_jdn(year + 1) - rosh_hashana_jdn(year)) as u16
}
fn hebrew_month_days(year: i64, month: HebrewMonth) -> u8 {
let year_len = hebrew_year_days(year);
match month {
HebrewMonth::Tishrei => 30,
HebrewMonth::Cheshvan => {
if year_len % 10 == 5 { 30 } else { 29 }
}
HebrewMonth::Kislev => {
if year_len % 10 == 3 { 29 } else { 30 }
}
HebrewMonth::Tevet => 29,
HebrewMonth::Shevat => 30,
HebrewMonth::Adar => 29,
HebrewMonth::AdarI => 30,
HebrewMonth::AdarII => 29,
HebrewMonth::Nisan => 30,
HebrewMonth::Iyar => 29,
HebrewMonth::Sivan => 30,
HebrewMonth::Tammuz => 29,
HebrewMonth::Av => 30,
HebrewMonth::Elul => 29,
}
}
#[must_use]
pub fn jdn_to_hebrew(jdn: f64) -> HebrewDate {
tracing::trace!(jdn, "JDN to Hebrew");
let jdn_int = (jdn + 0.5).floor() as i64;
let approx = ((jdn_int - 347_996) as f64 / 365.25).floor() as i64 + 1;
let mut year = approx;
while rosh_hashana_jdn(year + 1) <= jdn_int {
year += 1;
}
while rosh_hashana_jdn(year) > jdn_int {
year -= 1;
}
let day_of_year = jdn_int - rosh_hashana_jdn(year);
let is_leap = hebrew_is_leap(year);
let months: &[HebrewMonth] = if is_leap {
&LEAP_MONTHS
} else {
&COMMON_MONTHS
};
let mut remaining = day_of_year;
let mut month = months[0];
for &m in months {
let md = i64::from(hebrew_month_days(year, m));
if remaining < md {
month = m;
break;
}
remaining -= md;
month = m;
}
HebrewDate {
year,
month,
day: remaining as u8 + 1,
}
}
#[must_use = "returns the JDN or an error"]
pub fn hebrew_to_jdn(date: &HebrewDate) -> Result<f64> {
tracing::trace!(year = date.year, ?date.month, day = date.day, "Hebrew to JDN");
let is_leap = hebrew_is_leap(date.year);
if !is_leap && (date.month == HebrewMonth::AdarI || date.month == HebrewMonth::AdarII) {
return Err(SankhyaError::InvalidDate(format!(
"{} is not valid in common year {} AM",
date.month, date.year
)));
}
if is_leap && date.month == HebrewMonth::Adar {
return Err(SankhyaError::InvalidDate(format!(
"Adar is not valid in leap year {} AM (use Adar I or Adar II)",
date.year
)));
}
let max_day = hebrew_month_days(date.year, date.month);
if date.day == 0 || date.day > max_day {
return Err(SankhyaError::InvalidDate(format!(
"day {} out of range for {} in year {} AM (max {max_day})",
date.day, date.month, date.year
)));
}
let months: &[HebrewMonth] = if is_leap {
&LEAP_MONTHS
} else {
&COMMON_MONTHS
};
let mut day_of_year = i64::from(date.day) - 1;
for &m in months {
if m == date.month {
break;
}
day_of_year += i64::from(hebrew_month_days(date.year, m));
}
let jdn = rosh_hashana_jdn(date.year) + day_of_year;
Ok(jdn as f64 - 0.5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_roundtrip() {
let date = jdn_to_hebrew(HEBREW_EPOCH_JDN);
assert_eq!(date.year, 1);
assert_eq!(date.month, HebrewMonth::Tishrei);
assert_eq!(date.day, 1);
let jdn = hebrew_to_jdn(&date).unwrap();
assert!((jdn - HEBREW_EPOCH_JDN).abs() < f64::EPSILON);
}
#[test]
fn known_date_passover_5785() {
let date = HebrewDate {
year: 5785,
month: HebrewMonth::Nisan,
day: 15,
};
let jdn = hebrew_to_jdn(&date).unwrap();
let back = jdn_to_hebrew(jdn);
assert_eq!(back, date);
}
#[test]
fn leap_year_metonic() {
assert!(hebrew_is_leap(3));
assert!(hebrew_is_leap(6));
assert!(hebrew_is_leap(8));
assert!(hebrew_is_leap(11));
assert!(hebrew_is_leap(14));
assert!(hebrew_is_leap(17));
assert!(hebrew_is_leap(19));
assert!(!hebrew_is_leap(1));
assert!(!hebrew_is_leap(2));
assert!(!hebrew_is_leap(4));
assert!(!hebrew_is_leap(5));
}
#[test]
fn year_lengths_valid() {
for y in 1..=100 {
let days = hebrew_year_days(y);
assert!(
matches!(days, 353 | 354 | 355 | 383 | 384 | 385),
"year {y}: invalid length {days}"
);
if hebrew_is_leap(y) {
assert!(days >= 383, "leap year {y} has only {days} days");
} else {
assert!(days <= 355, "common year {y} has {days} days");
}
}
}
#[test]
fn adar_in_leap_year_errors() {
let date = HebrewDate {
year: 5784, month: HebrewMonth::Adar,
day: 1,
};
assert!(hebrew_to_jdn(&date).is_err());
}
#[test]
fn adar_i_in_common_year_errors() {
let date = HebrewDate {
year: 5785, month: HebrewMonth::AdarI,
day: 1,
};
assert!(hebrew_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_zero() {
let date = HebrewDate {
year: 5785,
month: HebrewMonth::Tishrei,
day: 0,
};
assert!(hebrew_to_jdn(&date).is_err());
}
#[test]
fn roundtrip_sequential_days() {
let start_jdn = 2_460_000.5;
for offset in 0..1500 {
let jdn = start_jdn + f64::from(offset);
let date = jdn_to_hebrew(jdn);
let back = hebrew_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: {date}"
);
}
}
#[test]
fn roundtrip_across_leap_boundary() {
for y in 5780..=5790 {
let rh_jdn = rosh_hashana_jdn(y) as f64 - 0.5;
let date = jdn_to_hebrew(rh_jdn);
assert_eq!(date.year, y);
assert_eq!(date.month, HebrewMonth::Tishrei);
assert_eq!(date.day, 1);
let back = hebrew_to_jdn(&date).unwrap();
assert!(
(back - rh_jdn).abs() < f64::EPSILON,
"Rosh Hashana roundtrip failed for year {y}"
);
}
}
#[test]
fn display_format() {
let date = HebrewDate {
year: 5785,
month: HebrewMonth::Nisan,
day: 15,
};
assert_eq!(date.to_string(), "15 Nisan 5785 AM");
}
#[test]
fn serde_roundtrip() {
let date = jdn_to_hebrew(2_460_000.5);
let json = serde_json::to_string(&date).unwrap();
let back: HebrewDate = serde_json::from_str(&json).unwrap();
assert_eq!(date, back);
}
#[test]
fn month_display() {
assert_eq!(HebrewMonth::Tishrei.to_string(), "Tishrei");
assert_eq!(HebrewMonth::AdarI.to_string(), "Adar I");
assert_eq!(HebrewMonth::AdarII.to_string(), "Adar II");
assert_eq!(HebrewMonth::Elul.to_string(), "Elul");
}
}