Skip to main content

jalali_calendar/
lib.rs

1//! # jalali-calendar
2//!
3//! A comprehensive Jalali (Persian / Shamsi) calendar library for Rust.
4//!
5//! Add it to `Cargo.toml` as `jalali-calendar = "0.1"` and import it as
6//! `use jalali_calendar::*;`.
7//!
8//! ## What's in the box
9//!
10//! - [`JalaliDate`] — a naive Jalali calendar date (year, month, day) with
11//!   validation, conversion to/from Gregorian, calendar arithmetic, and
12//!   convenience helpers (weekday, ordinal, season, week-of-year, etc.).
13//! - [`JalaliDateTime`] — a [`JalaliDate`] paired with a time of day.
14//! - [`Weekday`], [`Season`] — value types used throughout the API.
15//! - [`PERSIAN_MONTHS`] — the canonical Persian month names.
16//! - [`digits`] — convert between Latin (ASCII), Persian, and Eastern-Arabic
17//!   digits.
18//! - `strftime`-style [`JalaliDate::format`] / [`JalaliDate::parse_format`]
19//!   (and the matching [`JalaliDateTime`] methods). See the [`format`] module
20//!   docs for the supported tokens.
21//!
22//! ## Quick example
23//!
24//! ```
25//! use jalali_calendar::JalaliDate;
26//!
27//! // Convert from Gregorian.
28//! let nowruz = JalaliDate::from_gregorian(2024, 3, 20).unwrap();
29//! assert_eq!((nowruz.year(), nowruz.month(), nowruz.day()), (1403, 1, 1));
30//!
31//! // Format with strftime-style tokens (Persian month + season).
32//! assert_eq!(nowruz.format("%-d %B (%K)"), "1 فروردین (بهار)");
33//!
34//! // Parse Persian-digit input transparently.
35//! let parsed: JalaliDate = "۱۴۰۳/۰۱/۰۱".parse().unwrap();
36//! assert_eq!(parsed, nowruz);
37//!
38//! // Calendar arithmetic with month-end clamping.
39//! let next_month = JalaliDate::new(1403, 6, 31).unwrap().add_months(1);
40//! assert_eq!(next_month, JalaliDate::new(1403, 7, 30).unwrap());
41//! ```
42//!
43//! ## Date range
44//!
45//! The Pournader-Toossi conversion algorithm is accurate for Jalali years
46//! roughly **1..=3177 AP** (covering all practical contemporary use). Outside
47//! that range the leap-year approximation drifts.
48//!
49//! ## Cargo features
50//!
51//! All optional. The crate has zero required dependencies.
52//!
53//! | Feature    | Pulls in            | Adds                                                                 |
54//! |------------|---------------------|----------------------------------------------------------------------|
55//! | `serde`    | `serde`             | `Serialize`/`Deserialize` for [`JalaliDate`] and [`JalaliDateTime`]. |
56//! | `chrono`   | `chrono`            | Interop with `chrono::NaiveDate` / `chrono::NaiveDateTime`.          |
57//! | `timezone` | `chrono`,`chrono-tz`| [`ZonedJalaliDateTime`] anchored to a `chrono_tz::Tz`.               |
58//! | `full`     | all of the above    | Convenience flag.                                                    |
59//!
60//! Enable via `Cargo.toml`:
61//!
62//! ```toml
63//! jalali-calendar = { version = "0.1", features = ["serde", "chrono"] }
64//! ```
65
66#![cfg_attr(docsrs, feature(doc_cfg))]
67#![warn(missing_docs)]
68#![warn(rustdoc::broken_intra_doc_links)]
69
70use std::fmt;
71
72mod algorithm;
73mod datetime;
74pub mod digits;
75mod format;
76mod parse;
77mod season;
78mod today;
79mod unix;
80
81#[cfg(feature = "chrono")]
82mod chrono_impl;
83#[cfg(feature = "serde")]
84mod serde_impl;
85#[cfg(feature = "timezone")]
86mod zoned;
87
88pub use algorithm::{days_in_month, is_leap_year};
89pub use datetime::JalaliDateTime;
90pub use season::Season;
91
92#[cfg(feature = "timezone")]
93#[cfg_attr(docsrs, doc(cfg(feature = "timezone")))]
94pub use zoned::ZonedJalaliDateTime;
95
96/// Errors returned by this crate.
97///
98/// Variants are stable and exhaustively matched in the crate's public API.
99/// All variants implement [`Display`] (with a human-readable message) and
100/// [`std::error::Error`], so they integrate with `?` and error chains.
101///
102/// [`Display`]: std::fmt::Display
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Error {
105    /// The provided `(year, month, day)` does not describe a real Jalali date
106    /// (e.g. day 30 of Esfand in a non-leap year, or month 13).
107    InvalidJalaliDate {
108        /// Jalali year that was provided.
109        year: i32,
110        /// Jalali month that was provided.
111        month: u32,
112        /// Jalali day that was provided.
113        day: u32,
114    },
115    /// The provided `(year, month, day)` does not describe a real Gregorian
116    /// date (e.g. February 30, or month 0).
117    InvalidGregorianDate {
118        /// Gregorian year that was provided.
119        year: i32,
120        /// Gregorian month that was provided.
121        month: u32,
122        /// Gregorian day that was provided.
123        day: u32,
124    },
125    /// The time-of-day components were out of range
126    /// (`hour > 23`, `minute > 59`, `second > 59`, or
127    /// `nanosecond > 999_999_999`).
128    InvalidTime {
129        /// Hour that was provided.
130        hour: u32,
131        /// Minute that was provided.
132        minute: u32,
133        /// Second that was provided.
134        second: u32,
135    },
136    /// The string passed to [`JalaliDate::parse`] or [`str::parse`] (via the
137    /// [`std::str::FromStr`] impl) could not be split into year/month/day
138    /// components.
139    ///
140    /// The contained `String` is the original input.
141    InvalidJalaliInput(String),
142    /// A format string passed to [`JalaliDate::format`] or
143    /// [`JalaliDate::parse_format`] used a `%X` token the implementation
144    /// does not recognize.
145    UnknownFormatToken(char),
146    /// A parse against an `strftime`-style format string failed because the
147    /// input did not contain the literal text or numeric field expected at
148    /// that position.
149    ParseMismatch {
150        /// Description of what the parser was looking for.
151        expected: String,
152        /// The actual input (or a slice of it) that was found instead.
153        found: String,
154    },
155}
156
157impl fmt::Display for Error {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            Error::InvalidJalaliDate { year, month, day } => {
161                write!(f, "invalid Jalali date {year}/{month}/{day}")
162            }
163            Error::InvalidGregorianDate { year, month, day } => {
164                write!(f, "invalid Gregorian date {year}/{month}/{day}")
165            }
166            Error::InvalidTime {
167                hour,
168                minute,
169                second,
170            } => write!(f, "invalid time {hour:02}:{minute:02}:{second:02}"),
171            Error::InvalidJalaliInput(s) => write!(f, "could not parse Jalali date from {s:?}"),
172            Error::UnknownFormatToken(c) => write!(f, "unknown format token %{c}"),
173            Error::ParseMismatch { expected, found } => {
174                write!(f, "parse mismatch: expected {expected}, found {found:?}")
175            }
176        }
177    }
178}
179
180impl std::error::Error for Error {}
181
182/// A day of the Persian week.
183///
184/// Variants are declared in Persian week order — `Saturday` (شنبه) is the
185/// first day of the week and `Friday` (جمعه) is the last. This matches the
186/// numbering returned by [`Weekday::num_days_from_saturday`].
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum Weekday {
189    /// شنبه — first day of the Persian week.
190    Saturday,
191    /// یک‌شنبه.
192    Sunday,
193    /// دوشنبه.
194    Monday,
195    /// سه‌شنبه.
196    Tuesday,
197    /// چهارشنبه.
198    Wednesday,
199    /// پنج‌شنبه.
200    Thursday,
201    /// جمعه — last day of the Persian week.
202    Friday,
203}
204
205impl Weekday {
206    /// Full Persian (Farsi) name of the weekday.
207    ///
208    /// ```
209    /// # use jalali_calendar::Weekday;
210    /// assert_eq!(Weekday::Saturday.persian_name(), "شنبه");
211    /// assert_eq!(Weekday::Friday.persian_name(), "جمعه");
212    /// ```
213    pub fn persian_name(self) -> &'static str {
214        match self {
215            Weekday::Saturday => "شنبه",
216            Weekday::Sunday => "یک‌شنبه",
217            Weekday::Monday => "دوشنبه",
218            Weekday::Tuesday => "سه‌شنبه",
219            Weekday::Wednesday => "چهارشنبه",
220            Weekday::Thursday => "پنج‌شنبه",
221            Weekday::Friday => "جمعه",
222        }
223    }
224
225    /// Single-character Persian abbreviation (`ش`, `ی`, `د`, `س`, `چ`, `پ`,
226    /// `ج`).
227    ///
228    /// ```
229    /// # use jalali_calendar::Weekday;
230    /// assert_eq!(Weekday::Wednesday.persian_abbreviation(), "چ");
231    /// ```
232    pub fn persian_abbreviation(self) -> &'static str {
233        match self {
234            Weekday::Saturday => "ش",
235            Weekday::Sunday => "ی",
236            Weekday::Monday => "د",
237            Weekday::Tuesday => "س",
238            Weekday::Wednesday => "چ",
239            Weekday::Thursday => "پ",
240            Weekday::Friday => "ج",
241        }
242    }
243
244    /// English name of the weekday (`"Saturday"` … `"Friday"`).
245    pub fn english_name(self) -> &'static str {
246        match self {
247            Weekday::Saturday => "Saturday",
248            Weekday::Sunday => "Sunday",
249            Weekday::Monday => "Monday",
250            Weekday::Tuesday => "Tuesday",
251            Weekday::Wednesday => "Wednesday",
252            Weekday::Thursday => "Thursday",
253            Weekday::Friday => "Friday",
254        }
255    }
256
257    /// Position in the Persian week, with Saturday = 0 and Friday = 6.
258    ///
259    /// Useful when computing week numbers or laying out a calendar grid.
260    ///
261    /// ```
262    /// # use jalali_calendar::Weekday;
263    /// assert_eq!(Weekday::Saturday.num_days_from_saturday(), 0);
264    /// assert_eq!(Weekday::Friday.num_days_from_saturday(), 6);
265    /// ```
266    pub fn num_days_from_saturday(self) -> u32 {
267        match self {
268            Weekday::Saturday => 0,
269            Weekday::Sunday => 1,
270            Weekday::Monday => 2,
271            Weekday::Tuesday => 3,
272            Weekday::Wednesday => 4,
273            Weekday::Thursday => 5,
274            Weekday::Friday => 6,
275        }
276    }
277}
278
279/// A naive date on the Jalali (Persian / Shamsi) calendar.
280///
281/// "Naive" means the date carries no timezone information; it represents a
282/// calendar date as a human would write it. For a date paired with a time of
283/// day see [`JalaliDateTime`]; for a timezone-aware datetime enable the
284/// `timezone` Cargo feature and use [`ZonedJalaliDateTime`].
285///
286/// Internal fields are private — values are only constructible via
287/// validating constructors ([`JalaliDate::new`], [`JalaliDate::from_gregorian`],
288/// [`JalaliDate::from_unix_timestamp`], the [`std::str::FromStr`] impl, or
289/// the `chrono` interop methods), so a `JalaliDate` always represents a real
290/// Jalali date.
291///
292/// `JalaliDate` is `Copy` and ordered chronologically.
293///
294/// ```
295/// use jalali_calendar::JalaliDate;
296///
297/// let a = JalaliDate::new(1403, 1, 1)?;
298/// let b = JalaliDate::new(1403, 12, 30)?;
299/// assert!(a < b);
300/// assert_eq!(a.days_until(&b), 365);
301/// # Ok::<(), jalali_calendar::Error>(())
302/// ```
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
304pub struct JalaliDate {
305    year: i32,
306    month: u32,
307    day: u32,
308}
309
310impl JalaliDate {
311    /// Construct a Jalali date from raw components, validating month and day.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`Error::InvalidJalaliDate`] if `month` is not in `1..=12` or
316    /// `day` is outside the valid range for the given month and year (e.g.
317    /// day 30 of Esfand in a non-leap year).
318    ///
319    /// ```
320    /// # use jalali_calendar::JalaliDate;
321    /// assert!(JalaliDate::new(1403, 12, 30).is_ok());  // 1403 is leap
322    /// assert!(JalaliDate::new(1404, 12, 30).is_err()); // 1404 is not
323    /// ```
324    pub fn new(year: i32, month: u32, day: u32) -> Result<Self, Error> {
325        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
326            return Err(Error::InvalidJalaliDate { year, month, day });
327        }
328        Ok(JalaliDate { year, month, day })
329    }
330
331    /// Construct without validation. Internal use only — callers must
332    /// guarantee the components describe a real Jalali date.
333    pub(crate) fn new_unchecked(year: i32, month: u32, day: u32) -> Self {
334        JalaliDate { year, month, day }
335    }
336
337    /// Convert a Gregorian (proleptic) date to its Jalali equivalent.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`Error::InvalidGregorianDate`] if `(gy, gm, gd)` is not a
342    /// real Gregorian date.
343    ///
344    /// ```
345    /// # use jalali_calendar::JalaliDate;
346    /// let j = JalaliDate::from_gregorian(2024, 3, 20)?;
347    /// assert_eq!((j.year(), j.month(), j.day()), (1403, 1, 1));
348    /// # Ok::<(), jalali_calendar::Error>(())
349    /// ```
350    pub fn from_gregorian(gy: i32, gm: u32, gd: u32) -> Result<Self, Error> {
351        if !algorithm::is_valid_gregorian(gy, gm, gd) {
352            return Err(Error::InvalidGregorianDate {
353                year: gy,
354                month: gm,
355                day: gd,
356            });
357        }
358        let (y, m, d) = algorithm::g2j(gy, gm, gd);
359        Ok(JalaliDate {
360            year: y,
361            month: m,
362            day: d,
363        })
364    }
365
366    /// Convert this Jalali date to its Gregorian equivalent as
367    /// `(year, month, day)`.
368    ///
369    /// ```
370    /// # use jalali_calendar::JalaliDate;
371    /// let g = JalaliDate::new(1403, 1, 1)?.to_gregorian();
372    /// assert_eq!(g, (2024, 3, 20));
373    /// # Ok::<(), jalali_calendar::Error>(())
374    /// ```
375    pub fn to_gregorian(&self) -> (i32, u32, u32) {
376        algorithm::j2g(self.year, self.month, self.day)
377    }
378
379    /// Jalali year (e.g. `1403`).
380    pub fn year(&self) -> i32 {
381        self.year
382    }
383
384    /// Jalali month, in `1..=12` (1 = Farvardin … 12 = Esfand).
385    pub fn month(&self) -> u32 {
386        self.month
387    }
388
389    /// Day of the month (`1..=31`).
390    pub fn day(&self) -> u32 {
391        self.day
392    }
393
394    /// Return a new date offset from this one by `days`. Negative values move
395    /// backward.
396    ///
397    /// ```
398    /// # use jalali_calendar::JalaliDate;
399    /// let d = JalaliDate::new(1403, 1, 1)?;
400    /// assert_eq!(d.add_days(31), JalaliDate::new(1403, 2, 1)?);
401    /// assert_eq!(d.add_days(-1), JalaliDate::new(1402, 12, 29)?);
402    /// # Ok::<(), jalali_calendar::Error>(())
403    /// ```
404    pub fn add_days(&self, days: i32) -> Self {
405        let abs = algorithm::j_to_abs(self.year, self.month, self.day) + days;
406        let (y, m, d) = algorithm::abs_to_j(abs);
407        JalaliDate {
408            year: y,
409            month: m,
410            day: d,
411        }
412    }
413
414    /// Number of whole days from `self` to `other`. Negative when `other`
415    /// precedes `self`.
416    ///
417    /// `a.add_days(a.days_until(&b)) == b` for any two valid dates.
418    pub fn days_until(&self, other: &JalaliDate) -> i32 {
419        algorithm::j_to_abs(other.year, other.month, other.day)
420            - algorithm::j_to_abs(self.year, self.month, self.day)
421    }
422
423    /// Day of the week.
424    ///
425    /// ```
426    /// # use jalali_calendar::{JalaliDate, Weekday};
427    /// // Nowruz 1403 (2024-03-20) was a Wednesday.
428    /// assert_eq!(JalaliDate::new(1403, 1, 1)?.weekday(), Weekday::Wednesday);
429    /// # Ok::<(), jalali_calendar::Error>(())
430    /// ```
431    pub fn weekday(&self) -> Weekday {
432        // Rata Die day 1 (Jan 1, 1 CE) was a Monday, which is Persian
433        // weekday index 2 (Sat=0). Offsetting by +1 maps RD%7=0 -> Saturday.
434        let abs = algorithm::j_to_abs(self.year, self.month, self.day);
435        match (abs + 1).rem_euclid(7) {
436            0 => Weekday::Saturday,
437            1 => Weekday::Sunday,
438            2 => Weekday::Monday,
439            3 => Weekday::Tuesday,
440            4 => Weekday::Wednesday,
441            5 => Weekday::Thursday,
442            6 => Weekday::Friday,
443            _ => unreachable!(),
444        }
445    }
446
447    /// Day of the year, in `1..=365` (or `1..=366` in a leap year).
448    ///
449    /// ```
450    /// # use jalali_calendar::JalaliDate;
451    /// assert_eq!(JalaliDate::new(1403, 1, 1)?.ordinal(), 1);
452    /// assert_eq!(JalaliDate::new(1403, 12, 30)?.ordinal(), 366);
453    /// # Ok::<(), jalali_calendar::Error>(())
454    /// ```
455    pub fn ordinal(&self) -> u32 {
456        if self.month <= 6 {
457            (self.month - 1) * 31 + self.day
458        } else {
459            6 * 31 + (self.month - 7) * 30 + self.day
460        }
461    }
462
463    /// Persian name of the month (e.g. `"فروردین"`).
464    pub fn month_name(&self) -> &'static str {
465        PERSIAN_MONTHS[(self.month - 1) as usize]
466    }
467
468    /// Whether this date's year is a Jalali leap year (Esfand has 30 days
469    /// instead of 29).
470    pub fn is_leap_year(&self) -> bool {
471        algorithm::is_leap_year(self.year)
472    }
473
474    /// Number of days in this date's month (29, 30, or 31).
475    pub fn days_in_this_month(&self) -> u32 {
476        algorithm::days_in_month(self.year, self.month)
477    }
478
479    /// The [`Season`] this date falls in.
480    pub fn season(&self) -> Season {
481        Season::from_month(self.month).expect("month is validated 1..=12")
482    }
483
484    /// Week of the Jalali year, 1-based, weeks starting on Saturday.
485    ///
486    /// Week 1 contains 1 Farvardin and may be a partial week.
487    pub fn week_of_year(&self) -> u32 {
488        let first_wd = JalaliDate::new_unchecked(self.year, 1, 1)
489            .weekday()
490            .num_days_from_saturday();
491        (self.ordinal() + first_wd - 1) / 7 + 1
492    }
493
494    /// First day of this date's month — `(year, month, 1)`.
495    pub fn first_day_of_month(&self) -> Self {
496        JalaliDate::new_unchecked(self.year, self.month, 1)
497    }
498
499    /// Last day of this date's month — `(year, month, days_in_month)`.
500    pub fn last_day_of_month(&self) -> Self {
501        JalaliDate::new_unchecked(self.year, self.month, self.days_in_this_month())
502    }
503
504    /// 1 Farvardin of this date's year.
505    pub fn first_day_of_year(&self) -> Self {
506        JalaliDate::new_unchecked(self.year, 1, 1)
507    }
508
509    /// Last day of this date's year — 30 Esfand in leap years, 29 Esfand
510    /// otherwise.
511    pub fn last_day_of_year(&self) -> Self {
512        let day = if algorithm::is_leap_year(self.year) {
513            30
514        } else {
515            29
516        };
517        JalaliDate::new_unchecked(self.year, 12, day)
518    }
519
520    /// First day of this date's [`Season`].
521    pub fn first_day_of_season(&self) -> Self {
522        let (start, _) = self.season().months();
523        JalaliDate::new_unchecked(self.year, start, 1)
524    }
525
526    /// Last day of this date's [`Season`].
527    pub fn last_day_of_season(&self) -> Self {
528        let (_, end) = self.season().months();
529        JalaliDate::new_unchecked(self.year, end, algorithm::days_in_month(self.year, end))
530    }
531
532    /// Return a new date with the year replaced.
533    ///
534    /// If the current day does not fit in the same month of the target year
535    /// (only possible for `(month=12, day=30)` moving from a leap year to a
536    /// non-leap one), the day is clamped to the target month's length rather
537    /// than producing an error.
538    ///
539    /// # Errors
540    ///
541    /// Currently never fails for in-range inputs — the [`Result`] return
542    /// type is preserved for API symmetry with [`with_month`](Self::with_month).
543    pub fn with_year(&self, year: i32) -> Result<Self, Error> {
544        let max = algorithm::days_in_month(year, self.month);
545        let day = self.day.min(max);
546        JalaliDate::new(year, self.month, day)
547    }
548
549    /// Return a new date with the month replaced. The day is clamped to the
550    /// target month's length.
551    ///
552    /// # Errors
553    ///
554    /// Returns [`Error::InvalidJalaliDate`] if `month` is outside `1..=12`.
555    pub fn with_month(&self, month: u32) -> Result<Self, Error> {
556        if !(1..=12).contains(&month) {
557            return Err(Error::InvalidJalaliDate {
558                year: self.year,
559                month,
560                day: self.day,
561            });
562        }
563        let max = algorithm::days_in_month(self.year, month);
564        let day = self.day.min(max);
565        JalaliDate::new(self.year, month, day)
566    }
567
568    /// Return a new date with the day replaced.
569    ///
570    /// # Errors
571    ///
572    /// Returns [`Error::InvalidJalaliDate`] if `day` exceeds the current
573    /// month's length.
574    pub fn with_day(&self, day: u32) -> Result<Self, Error> {
575        JalaliDate::new(self.year, self.month, day)
576    }
577
578    /// Add (or subtract) calendar months. The day is clamped to the target
579    /// month's length.
580    ///
581    /// ```
582    /// # use jalali_calendar::JalaliDate;
583    /// // Mehr (month 7) only has 30 days, so day 31 clamps.
584    /// let d = JalaliDate::new(1403, 6, 31)?.add_months(1);
585    /// assert_eq!(d, JalaliDate::new(1403, 7, 30)?);
586    /// # Ok::<(), jalali_calendar::Error>(())
587    /// ```
588    pub fn add_months(&self, months: i32) -> Self {
589        let total = self.year as i64 * 12 + (self.month as i64 - 1) + months as i64;
590        let new_year = total.div_euclid(12) as i32;
591        let new_month = (total.rem_euclid(12) as u32) + 1;
592        let max = algorithm::days_in_month(new_year, new_month);
593        JalaliDate::new_unchecked(new_year, new_month, self.day.min(max))
594    }
595
596    /// Add (or subtract) calendar years. Esfand 30 in a leap year is clamped
597    /// to Esfand 29 if the target year is not leap.
598    ///
599    /// ```
600    /// # use jalali_calendar::JalaliDate;
601    /// let d = JalaliDate::new(1403, 12, 30)?; // 1403 is leap
602    /// assert_eq!(d.add_years(1), JalaliDate::new(1404, 12, 29)?);
603    /// # Ok::<(), jalali_calendar::Error>(())
604    /// ```
605    pub fn add_years(&self, years: i32) -> Self {
606        let new_year = self.year + years;
607        let max = algorithm::days_in_month(new_year, self.month);
608        JalaliDate::new_unchecked(new_year, self.month, self.day.min(max))
609    }
610}
611
612impl fmt::Display for JalaliDate {
613    /// Formats as `YYYY/MM/DD` with zero-padded month and day.
614    ///
615    /// For richer formatting (Persian month names, AM/PM, etc.) use
616    /// [`JalaliDate::format`].
617    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
618        write!(f, "{:04}/{:02}/{:02}", self.year, self.month, self.day)
619    }
620}
621
622/// The twelve Persian month names in calendar order: Farvardin (`فروردین`),
623/// Ordibehesht (`اردیبهشت`), …, Esfand (`اسفند`).
624///
625/// Indexed as `PERSIAN_MONTHS[month - 1]`.
626pub const PERSIAN_MONTHS: [&str; 12] = [
627    "فروردین",
628    "اردیبهشت",
629    "خرداد",
630    "تیر",
631    "مرداد",
632    "شهریور",
633    "مهر",
634    "آبان",
635    "آذر",
636    "دی",
637    "بهمن",
638    "اسفند",
639];
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn nowruz_1403_is_march_20_2024() {
647        let j = JalaliDate::from_gregorian(2024, 3, 20).unwrap();
648        assert_eq!(j.year(), 1403);
649        assert_eq!(j.month(), 1);
650        assert_eq!(j.day(), 1);
651    }
652
653    #[test]
654    fn nowruz_1402_is_march_21_2023() {
655        let j = JalaliDate::from_gregorian(2023, 3, 21).unwrap();
656        assert_eq!((j.year(), j.month(), j.day()), (1402, 1, 1));
657    }
658
659    #[test]
660    fn round_trip_known_dates() {
661        let pairs = [
662            ((2024, 3, 20), (1403, 1, 1)),
663            ((2024, 3, 19), (1402, 12, 29)),
664            ((2025, 3, 21), (1404, 1, 1)),
665            ((1979, 2, 11), (1357, 11, 22)),
666            ((1989, 6, 4), (1368, 3, 14)),
667            ((2001, 9, 11), (1380, 6, 20)),
668            ((2020, 1, 1), (1398, 10, 11)),
669            ((2026, 4, 30), (1405, 2, 10)),
670        ];
671        for ((gy, gm, gd), (jy, jm, jd)) in pairs {
672            let j = JalaliDate::from_gregorian(gy, gm, gd).unwrap();
673            assert_eq!(
674                (j.year(), j.month(), j.day()),
675                (jy, jm, jd),
676                "G->J wrong for {gy}-{gm}-{gd}"
677            );
678            assert_eq!(
679                j.to_gregorian(),
680                (gy, gm, gd),
681                "J->G wrong for {jy}-{jm}-{jd}"
682            );
683        }
684    }
685
686    #[test]
687    fn leap_year_detection() {
688        for y in [1399, 1403, 1408, 1412, 1416, 1420, 1424] {
689            assert!(is_leap_year(y), "{y} should be leap");
690            assert_eq!(days_in_month(y, 12), 30);
691        }
692        for y in [1400, 1401, 1402, 1404, 1405, 1406, 1407] {
693            assert!(!is_leap_year(y), "{y} should not be leap");
694            assert_eq!(days_in_month(y, 12), 29);
695        }
696    }
697
698    #[test]
699    fn days_in_each_month() {
700        for m in 1..=6 {
701            assert_eq!(days_in_month(1404, m), 31);
702        }
703        for m in 7..=11 {
704            assert_eq!(days_in_month(1404, m), 30);
705        }
706        assert_eq!(days_in_month(1404, 12), 29);
707        assert_eq!(days_in_month(1403, 12), 30);
708    }
709
710    #[test]
711    fn invalid_dates_rejected() {
712        assert!(JalaliDate::new(1404, 0, 1).is_err());
713        assert!(JalaliDate::new(1404, 13, 1).is_err());
714        assert!(JalaliDate::new(1404, 1, 32).is_err());
715        assert!(JalaliDate::new(1404, 7, 31).is_err());
716        assert!(JalaliDate::new(1404, 12, 30).is_err());
717        assert!(JalaliDate::new(1403, 12, 30).is_ok());
718    }
719
720    #[test]
721    fn weekday_lookup() {
722        let j = JalaliDate::new(1403, 1, 1).unwrap();
723        assert_eq!(j.weekday(), Weekday::Wednesday);
724
725        let j = JalaliDate::new(1402, 1, 1).unwrap();
726        assert_eq!(j.weekday(), Weekday::Tuesday);
727
728        let j = JalaliDate::new(1357, 11, 22).unwrap();
729        assert_eq!(j.weekday(), Weekday::Sunday);
730    }
731
732    #[test]
733    fn add_days_basic() {
734        let j = JalaliDate::new(1403, 1, 1).unwrap();
735        assert_eq!(j.add_days(1), JalaliDate::new(1403, 1, 2).unwrap());
736        assert_eq!(j.add_days(31), JalaliDate::new(1403, 2, 1).unwrap());
737        assert_eq!(j.add_days(-1), JalaliDate::new(1402, 12, 29).unwrap());
738        assert_eq!(j.add_days(366), JalaliDate::new(1404, 1, 1).unwrap());
739        let j2 = JalaliDate::new(1404, 1, 1).unwrap();
740        assert_eq!(j2.add_days(365), JalaliDate::new(1405, 1, 1).unwrap());
741    }
742
743    #[test]
744    fn days_until_round_trip() {
745        let a = JalaliDate::new(1403, 1, 1).unwrap();
746        let b = JalaliDate::new(1404, 5, 17).unwrap();
747        let n = a.days_until(&b);
748        assert!(n > 0);
749        assert_eq!(a.add_days(n), b);
750        assert_eq!(b.days_until(&a), -n);
751    }
752
753    #[test]
754    fn ordinal_day() {
755        assert_eq!(JalaliDate::new(1403, 1, 1).unwrap().ordinal(), 1);
756        assert_eq!(JalaliDate::new(1403, 6, 31).unwrap().ordinal(), 186);
757        assert_eq!(JalaliDate::new(1403, 7, 1).unwrap().ordinal(), 187);
758        assert_eq!(JalaliDate::new(1403, 12, 30).unwrap().ordinal(), 366);
759        assert_eq!(JalaliDate::new(1404, 12, 29).unwrap().ordinal(), 365);
760    }
761
762    #[test]
763    fn display_formatting() {
764        let j = JalaliDate::new(1403, 1, 1).unwrap();
765        assert_eq!(j.to_string(), "1403/01/01");
766        let j = JalaliDate::new(1404, 12, 29).unwrap();
767        assert_eq!(j.to_string(), "1404/12/29");
768    }
769
770    #[test]
771    fn invalid_gregorian_rejected() {
772        assert!(JalaliDate::from_gregorian(2024, 2, 30).is_err());
773        assert!(JalaliDate::from_gregorian(2024, 13, 1).is_err());
774        assert!(JalaliDate::from_gregorian(2023, 2, 29).is_err());
775        assert!(JalaliDate::from_gregorian(2024, 2, 29).is_ok());
776    }
777
778    #[test]
779    fn month_name_is_persian() {
780        assert_eq!(JalaliDate::new(1403, 1, 1).unwrap().month_name(), "فروردین");
781        assert_eq!(JalaliDate::new(1403, 12, 1).unwrap().month_name(), "اسفند");
782    }
783}