pcal/
shahanshahi.rs

1//! Shahanshahi (Imperial Persian) calendar conversion.
2//!
3//! The Shahanshahi calendar uses the same months and leap year rules as the
4//! Solar Hijri (Jalali) calendar, but counts years from the founding of the
5//! Persian Empire by Cyrus the Great. The year offset is +1180 relative to
6//! Jalali (e.g. Jalali 1404 = Shahanshahi 2584).
7//!
8//! Internally, conversion to/from Gregorian uses the Jalali algorithm with
9//! the offset applied at the public API boundary.
10
11use chrono::{Datelike, Local, NaiveDate, Weekday};
12
13use crate::persian::MONTH_DAYS;
14
15/// Offset between Shahanshahi and Jalali year numbering.
16const SHAHANSHAHI_OFFSET: i32 = 1180;
17
18/// Convert a Shahanshahi year to the internal Jalali year.
19const fn to_jalali_year(sy: i32) -> i32 {
20    sy - SHAHANSHAHI_OFFSET
21}
22
23/// Convert an internal Jalali year to Shahanshahi.
24const fn to_shahanshahi_year(jy: i32) -> i32 {
25    jy + SHAHANSHAHI_OFFSET
26}
27
28/// Convert a Gregorian date to Shahanshahi date.
29/// Returns (year, month, day) where month is 1-based.
30#[must_use]
31pub const fn gregorian_to_shahanshahi(gy: i32, gm: u32, gd: u32) -> (i32, u32, u32) {
32    let (jy, jm, jd) = gregorian_to_jalali(gy, gm, gd);
33    (to_shahanshahi_year(jy), jm, jd)
34}
35
36/// Convert a Shahanshahi date to Gregorian.
37/// Returns (year, month, day).
38#[must_use]
39pub fn shahanshahi_to_gregorian(sy: i32, sm: u32, sd: u32) -> (i32, u32, u32) {
40    jalali_to_gregorian(to_jalali_year(sy), sm, sd)
41}
42
43/// Check if a Shahanshahi year is a leap year.
44/// Uses the 33-year sub-cycle on the underlying Jalali year.
45#[must_use]
46pub const fn is_leap(sy: i32) -> bool {
47    let jy = to_jalali_year(sy);
48    let r = jy.rem_euclid(33);
49    matches!(r, 1 | 5 | 9 | 13 | 17 | 22 | 26 | 30)
50}
51
52/// Get the number of days in a given Shahanshahi month.
53#[must_use]
54pub const fn days_in_month(year: i32, month: u32) -> u32 {
55    if month == 12 && is_leap(year) {
56        30
57    } else {
58        MONTH_DAYS[month as usize - 1]
59    }
60}
61
62/// Get today's date in the Shahanshahi calendar.
63#[must_use]
64pub fn today() -> (i32, u32, u32) {
65    let now = Local::now();
66    gregorian_to_shahanshahi(now.year(), now.month(), now.day())
67}
68
69/// Get the weekday of the first day of a Shahanshahi month.
70/// Returns 0=Saturday, 1=Sunday, ..., 6=Friday (Iranian week).
71///
72/// # Panics
73///
74/// Panics if the computed Gregorian date is invalid, which cannot happen for
75/// any valid Shahanshahi `(year, month)` since the 1st of every month maps to
76/// a real calendar date.
77#[must_use]
78pub fn weekday_of_first(year: i32, month: u32) -> u32 {
79    let (gy, gm, gd) = shahanshahi_to_gregorian(year, month, 1);
80    let date = NaiveDate::from_ymd_opt(gy, gm, gd).expect("invalid date");
81    match date.weekday() {
82        Weekday::Sat => 0,
83        Weekday::Sun => 1,
84        Weekday::Mon => 2,
85        Weekday::Tue => 3,
86        Weekday::Wed => 4,
87        Weekday::Thu => 5,
88        Weekday::Fri => 6,
89    }
90}
91
92// --- Internal Jalali conversion algorithm (unchanged) ---
93
94const fn gregorian_to_jalali(gy: i32, gm: u32, gd: u32) -> (i32, u32, u32) {
95    let g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
96
97    let gy2 = if gm > 2 { gy + 1 } else { gy };
98    let mut days = 355_666 + (365 * gy) + ((gy2 + 3) / 4) - ((gy2 + 99) / 100)
99        + ((gy2 + 399) / 400)
100        + gd as i32
101        + g_d_m[gm as usize - 1];
102
103    let mut jy = -1595 + (33 * (days / 12_053));
104    days %= 12_053;
105
106    jy += 4 * (days / 1461);
107    days %= 1461;
108
109    if days > 365 {
110        jy += (days - 1) / 365;
111        days = (days - 1) % 365;
112    }
113
114    let (jm, jd) = if days < 186 {
115        let jm = 1 + (days / 31);
116        let jd = 1 + (days % 31);
117        (jm, jd)
118    } else {
119        let days = days - 186;
120        let jm = 7 + (days / 30);
121        let jd = 1 + (days % 30);
122        (jm, jd)
123    };
124
125    (jy, jm as u32, jd as u32)
126}
127
128fn jalali_to_gregorian(jy: i32, jm: u32, jd: u32) -> (i32, u32, u32) {
129    let jy = jy - 979;
130    let jm = jm as i32 - 1;
131    let jd = jd as i32 - 1;
132
133    let mut j_day_no = 365 * jy + (jy / 33) * 8 + (jy % 33 + 3) / 4;
134    for i in 0..jm {
135        j_day_no += if i < 6 { 31 } else { 30 };
136    }
137    j_day_no += jd;
138
139    let mut g_day_no = j_day_no + 79;
140
141    let mut gy = 1600 + 400 * (g_day_no / 146_097);
142    g_day_no %= 146_097;
143
144    let mut leap = true;
145    if g_day_no >= 36_525 {
146        g_day_no -= 1;
147        gy += 100 * (g_day_no / 36_524);
148        g_day_no %= 36_524;
149
150        if g_day_no >= 365 {
151            g_day_no += 1;
152        } else {
153            leap = false;
154        }
155    }
156
157    gy += 4 * (g_day_no / 1461);
158    g_day_no %= 1461;
159
160    if g_day_no >= 366 {
161        leap = false;
162        g_day_no -= 1;
163        gy += g_day_no / 365;
164        g_day_no %= 365;
165    }
166
167    let g_d_m: [i32; 12] = [
168        31,
169        if leap { 29 } else { 28 },
170        31,
171        30,
172        31,
173        30,
174        31,
175        31,
176        30,
177        31,
178        30,
179        31,
180    ];
181
182    let mut gm = 0;
183    for (i, &days) in g_d_m.iter().enumerate() {
184        if g_day_no < days {
185            break;
186        }
187        g_day_no -= days;
188        gm = i + 1;
189    }
190
191    (gy, gm as u32 + 1, g_day_no as u32 + 1)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_gregorian_to_shahanshahi() {
200        // 2025-03-20 = Shahanshahi 2583/12/30
201        assert_eq!(gregorian_to_shahanshahi(2025, 3, 20), (2583, 12, 30));
202        // 2025-03-21 = Shahanshahi 2584/01/01 (Nowruz)
203        assert_eq!(gregorian_to_shahanshahi(2025, 3, 21), (2584, 1, 1));
204        // 2024-03-20 = Shahanshahi 2583/01/01
205        assert_eq!(gregorian_to_shahanshahi(2024, 3, 20), (2583, 1, 1));
206        // 2026-03-15 = Shahanshahi 2584/12/24
207        assert_eq!(gregorian_to_shahanshahi(2026, 3, 15), (2584, 12, 24));
208    }
209
210    #[test]
211    fn test_shahanshahi_to_gregorian() {
212        assert_eq!(shahanshahi_to_gregorian(2584, 1, 1), (2025, 3, 21));
213        assert_eq!(shahanshahi_to_gregorian(2583, 1, 1), (2024, 3, 20));
214        assert_eq!(shahanshahi_to_gregorian(2584, 12, 24), (2026, 3, 15));
215    }
216
217    #[test]
218    fn test_roundtrip() {
219        for (sy, sm, sd) in [(2584, 1, 1), (2583, 6, 15), (2580, 12, 29), (2579, 12, 30)] {
220            let (gy, gm, gd) = shahanshahi_to_gregorian(sy, sm, sd);
221            assert_eq!(gregorian_to_shahanshahi(gy, gm, gd), (sy, sm, sd));
222        }
223    }
224
225    #[test]
226    fn test_is_leap() {
227        // Jalali 1399 (leap) = Shahanshahi 2579
228        assert!(is_leap(2579));
229        // Jalali 1403 (leap) = Shahanshahi 2583
230        assert!(is_leap(2583));
231        // Jalali 1404 (not leap) = Shahanshahi 2584
232        assert!(!is_leap(2584));
233        // Jalali 1401 (not leap) = Shahanshahi 2581
234        assert!(!is_leap(2581));
235    }
236
237    #[test]
238    fn test_days_in_month() {
239        assert_eq!(days_in_month(2584, 1), 31);
240        assert_eq!(days_in_month(2584, 7), 30);
241        assert_eq!(days_in_month(2584, 12), 29); // non-leap
242        assert_eq!(days_in_month(2583, 12), 30); // leap
243    }
244
245    #[test]
246    fn test_weekday_of_first() {
247        // 2584/01/01 = 2025-03-21 = Friday = 6 in our system
248        assert_eq!(weekday_of_first(2584, 1), 6);
249    }
250}