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}