use serde::{Deserialize, Serialize};
use crate::error::{Result, SankhyaError};
pub const PERSIAN_EPOCH_JDN: f64 = 1_948_320.5;
const PERSIAN_MONTH_DAYS: [u8; 12] = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PersianMonth {
Farvardin,
Ordibehesht,
Khordad,
Tir,
Mordad,
Shahrivar,
Mehr,
Aban,
Azar,
Dey,
Bahman,
Esfand,
}
const PERSIAN_MONTHS: [PersianMonth; 12] = [
PersianMonth::Farvardin,
PersianMonth::Ordibehesht,
PersianMonth::Khordad,
PersianMonth::Tir,
PersianMonth::Mordad,
PersianMonth::Shahrivar,
PersianMonth::Mehr,
PersianMonth::Aban,
PersianMonth::Azar,
PersianMonth::Dey,
PersianMonth::Bahman,
PersianMonth::Esfand,
];
impl core::fmt::Display for PersianMonth {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Farvardin => "Farvardin",
Self::Ordibehesht => "Ordibehesht",
Self::Khordad => "Khordad",
Self::Tir => "Tir",
Self::Mordad => "Mordad",
Self::Shahrivar => "Shahrivar",
Self::Mehr => "Mehr",
Self::Aban => "Aban",
Self::Azar => "Azar",
Self::Dey => "Dey",
Self::Bahman => "Bahman",
Self::Esfand => "Esfand",
};
write!(f, "{name}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersianDate {
pub year: i64,
pub month: PersianMonth,
pub day: u8,
}
impl core::fmt::Display for PersianDate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{} {} {} AP", self.day, self.month, self.year)
}
}
#[must_use]
pub fn persian_is_leap(year: i64) -> bool {
jalaali_cal(year).0 == 0
}
fn jalaali_cal(jy: i64) -> (i64, i64, i64) {
const BREAKS: [i64; 20] = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324,
2394, 2456, 2526,
];
let gy = jy + 621;
let mut leap_j = -14i64;
let mut jp = BREAKS[0];
let mut jump = 0i64;
for &jm in &BREAKS[1..] {
jump = jm - jp;
if jy < jm {
break;
}
leap_j += (jump / 33) * 8 + (jump % 33) / 4;
jp = jm;
}
let mut n = jy - jp;
if jump - n < 6 {
n = n - jump + (jump + 4) / 33 * 33;
}
let leap = ((n + 1) % 33 - 1) % 4;
let leap = if leap < 0 { leap + 4 } else { leap };
let n_orig = jy - jp;
leap_j += (n_orig / 33) * 8 + (n_orig % 33 + 3) / 4;
if jump % 33 == 4 && jump - n_orig == 4 {
leap_j += 1;
}
let leap_g = gy / 4 - ((gy / 100 + 1) * 3) / 4 - 150;
let march = 20 + leap_j - leap_g;
(leap, leap_j, march)
}
#[must_use]
#[inline]
pub fn persian_year_days(year: i64) -> u16 {
if persian_is_leap(year) { 366 } else { 365 }
}
#[must_use]
pub fn jdn_to_persian(jdn: f64) -> PersianDate {
tracing::trace!(jdn, "JDN to Persian");
let gy = crate::gregorian::jdn_to_gregorian(jdn);
let gy_year = gy.year;
let mut jy = gy_year - 621;
let (_, _, march) = jalaali_cal(jy);
let nowruz_jdn = gregorian_march_jdn(gy_year, march);
let day_diff = (jdn - nowruz_jdn).floor() as i64;
if day_diff < 0 {
jy -= 1;
let (_, _, march_prev) = jalaali_cal(jy);
let nowruz_prev = gregorian_march_jdn(gy_year - 1, march_prev);
let doy = (jdn - nowruz_prev).floor() as i64;
let (month_idx, day) = month_day_from_doy(doy, persian_is_leap(jy));
return PersianDate {
year: jy,
month: PERSIAN_MONTHS[month_idx],
day,
};
}
let yd = persian_year_days(jy) as i64;
if day_diff >= yd {
let doy = day_diff - yd;
jy += 1;
let (month_idx, day) = month_day_from_doy(doy, persian_is_leap(jy));
return PersianDate {
year: jy,
month: PERSIAN_MONTHS[month_idx],
day,
};
}
let (month_idx, day) = month_day_from_doy(day_diff, persian_is_leap(jy));
PersianDate {
year: jy,
month: PERSIAN_MONTHS[month_idx],
day,
}
}
#[must_use = "returns the JDN or an error"]
pub fn persian_to_jdn(date: &PersianDate) -> Result<f64> {
tracing::trace!(year = date.year, ?date.month, day = date.day, "Persian to JDN");
let month_idx = PERSIAN_MONTHS
.iter()
.position(|&m| m == date.month)
.unwrap_or(0);
let max_day = if month_idx == 11 && persian_is_leap(date.year) {
30
} else {
PERSIAN_MONTH_DAYS[month_idx]
};
if date.day == 0 || date.day > max_day {
return Err(SankhyaError::InvalidDate(format!(
"day {} out of range for {} in year {} AP (max {max_day})",
date.day, date.month, date.year
)));
}
let (_, _, march) = jalaali_cal(date.year);
let gy = date.year + 621;
let nowruz_jdn = gregorian_march_jdn(gy, march);
let mut day_of_year = i64::from(date.day) - 1;
for &md in &PERSIAN_MONTH_DAYS[..month_idx] {
day_of_year += i64::from(md);
}
Ok(nowruz_jdn + day_of_year as f64)
}
fn gregorian_march_jdn(gy: i64, march_day: i64) -> f64 {
match crate::gregorian::gregorian_to_jdn(&crate::gregorian::GregorianDate {
year: gy,
month: crate::gregorian::GregorianMonth::March,
day: march_day as u8,
}) {
Ok(jdn) => jdn,
Err(_) => crate::gregorian::GREGORIAN_EPOCH_JDN + (gy as f64 * 365.25) + 78.0,
}
}
fn month_day_from_doy(mut doy: i64, is_leap: bool) -> (usize, u8) {
for (i, &md) in PERSIAN_MONTH_DAYS.iter().enumerate() {
let md_i64 = if i == 11 && is_leap {
30
} else {
i64::from(md)
};
if doy < md_i64 {
return (i, doy as u8 + 1);
}
doy -= md_i64;
}
(11, doy as u8 + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_roundtrip() {
let date = jdn_to_persian(PERSIAN_EPOCH_JDN);
assert_eq!(date.year, 1);
assert_eq!(date.month, PersianMonth::Farvardin);
assert_eq!(date.day, 1);
let jdn = persian_to_jdn(&date).unwrap();
assert!((jdn - PERSIAN_EPOCH_JDN).abs() < f64::EPSILON);
}
#[test]
fn known_date_nowruz_1404() {
let date = jdn_to_persian(2_460_755.5);
assert_eq!(date.year, 1404);
assert_eq!(date.month, PersianMonth::Farvardin);
assert_eq!(date.day, 1);
}
#[test]
fn known_date_nowruz_1400() {
let date = jdn_to_persian(2_459_294.5);
assert_eq!(date.year, 1400);
assert_eq!(date.month, PersianMonth::Farvardin);
assert_eq!(date.day, 1);
}
#[test]
fn leap_year_known() {
assert!(persian_is_leap(1399));
assert!(persian_is_leap(1403));
assert!(persian_is_leap(1408));
assert!(!persian_is_leap(1400));
assert!(!persian_is_leap(1401));
assert!(!persian_is_leap(1402));
}
#[test]
fn year_days_values() {
assert_eq!(persian_year_days(1399), 366);
assert_eq!(persian_year_days(1400), 365);
}
#[test]
fn esfand_leap_30_days() {
let date = PersianDate {
year: 1399,
month: PersianMonth::Esfand,
day: 30,
};
assert!(persian_to_jdn(&date).is_ok());
}
#[test]
fn esfand_common_rejects_30() {
let date = PersianDate {
year: 1400,
month: PersianMonth::Esfand,
day: 30,
};
assert!(persian_to_jdn(&date).is_err());
}
#[test]
fn invalid_day_zero() {
let date = PersianDate {
year: 1400,
month: PersianMonth::Farvardin,
day: 0,
};
assert!(persian_to_jdn(&date).is_err());
}
#[test]
fn roundtrip_sequential_days() {
let start = 2_459_294.5; for offset in 0..1500 {
let jdn = start + f64::from(offset);
let date = jdn_to_persian(jdn);
let back = persian_to_jdn(&date).unwrap();
assert!(
(back - jdn).abs() < f64::EPSILON,
"roundtrip failed for JDN {jdn}: {date}"
);
}
}
#[test]
fn roundtrip_across_leap_boundary() {
let last_1399 = persian_to_jdn(&PersianDate {
year: 1399,
month: PersianMonth::Esfand,
day: 30,
})
.unwrap();
let first_1400 = persian_to_jdn(&PersianDate {
year: 1400,
month: PersianMonth::Farvardin,
day: 1,
})
.unwrap();
assert!((first_1400 - last_1399 - 1.0).abs() < f64::EPSILON);
let d1 = jdn_to_persian(last_1399);
assert_eq!(d1.year, 1399);
assert_eq!(d1.month, PersianMonth::Esfand);
assert_eq!(d1.day, 30);
let d2 = jdn_to_persian(first_1400);
assert_eq!(d2.year, 1400);
assert_eq!(d2.month, PersianMonth::Farvardin);
assert_eq!(d2.day, 1);
}
#[test]
fn display_format() {
let date = PersianDate {
year: 1404,
month: PersianMonth::Farvardin,
day: 1,
};
assert_eq!(date.to_string(), "1 Farvardin 1404 AP");
}
#[test]
fn serde_roundtrip() {
let date = jdn_to_persian(2_459_294.5);
let json = serde_json::to_string(&date).unwrap();
let back: PersianDate = serde_json::from_str(&json).unwrap();
assert_eq!(date, back);
}
#[test]
fn month_display() {
assert_eq!(PersianMonth::Farvardin.to_string(), "Farvardin");
assert_eq!(PersianMonth::Esfand.to_string(), "Esfand");
assert_eq!(PersianMonth::Mehr.to_string(), "Mehr");
}
}