Skip to main content

nepdate_patro/
lib.rs

1#![doc = include_str!("../README.md")]
2#![no_std]
3#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use core::iter::FusedIterator;
7
8use nepdate::{Lang, NepaliDate, NepaliDateRange};
9
10mod generated {
11    pub(crate) mod patro;
12}
13
14use crate::generated::patro::{
15    COVERAGE_FIRST, COVERAGE_LAST, DAY_EVENTS, DAY_TITHI, EVENT_IDS, HOLIDAY_BITS, HOLIDAY_OFFSETS,
16    NAMES, NO_TITHI, PATRO_DAYS, PATRO_FIRST, TITHI_NAMES,
17};
18
19// Table facts the lookups rely on, checked when the crate compiles.
20const _: () = assert!(TITHI_NAMES.len() < NO_TITHI as usize);
21const _: () = assert!(NAMES.len() <= u16::MAX as usize);
22const _: () = assert!(EVENT_IDS.len() <= u16::MAX as usize);
23const _: () = assert!(HOLIDAY_BITS.len() * 64 >= PATRO_DAYS);
24// Counts from the data on 2026-10-07. A data update changes them on purpose.
25const _: () = assert!(PATRO_DAYS == 2793);
26const _: () = assert!(HOLIDAY_OFFSETS.len() == 294);
27const _: () = assert!(EVENT_IDS.len() == 3032);
28const _: () = assert!(NAMES.len() == 721);
29const _: () = assert!(TITHI_NAMES.len() == 32);
30
31/// `PATRO_FIRST` as an index. Widening `u32` to `usize` is lossless on 32- and 64-bit targets.
32const FIRST: usize = PATRO_FIRST as usize;
33
34// The `panic!` arms run only while compiling, so a bad table fails the build, never a caller.
35#[allow(clippy::panic)]
36const COVERAGE: NepaliDateRange = {
37    let Ok(first) = NepaliDate::new(COVERAGE_FIRST.0, COVERAGE_FIRST.1, COVERAGE_FIRST.2) else {
38        panic!("COVERAGE_FIRST is not a valid BS date");
39    };
40    let Ok(last) = NepaliDate::new(COVERAGE_LAST.0, COVERAGE_LAST.1, COVERAGE_LAST.2) else {
41        panic!("COVERAGE_LAST is not a valid BS date");
42    };
43    match NepaliDateRange::new(first, last) {
44        Some(range) => range,
45        None => panic!("COVERAGE_FIRST is after COVERAGE_LAST"),
46    }
47};
48
49/// Info of a day without data: no tithi, no holiday, no events.
50const EMPTY: CalendarInfo = CalendarInfo {
51    tithi: NO_TITHI,
52    holiday: false,
53    events: (0, 0),
54};
55
56/// The tithi, public holiday flag and events of one BS day (PATRO §2).
57///
58/// Never fails: a day without data, inside or outside [`coverage`], gets an empty info.
59///
60/// ```
61/// use nepdate::{Lang, NepaliDate};
62///
63/// let info = nepdate_patro::info(NepaliDate::new(2081, 1, 1)?);
64/// assert_eq!(info.tithi().map(|t| t.name(Lang::En)), Some("Panchami"));
65/// assert!(info.is_public_holiday());
66/// let events: Vec<&str> = info.events().map(|e| e.name(Lang::En)).collect();
67/// assert_eq!(events, ["New Year", "Mesh Sankranti", "Bisket Jatra"]);
68/// assert_eq!(info.events().next().map(|e| e.name(Lang::Ne)), Some("नयाँ वर्ष"));
69///
70/// let none = nepdate_patro::info(NepaliDate::new(2090, 1, 1)?);
71/// assert!(none.tithi().is_none() && !none.is_public_holiday() && !none.has_events());
72/// # Ok::<(), nepdate::Error>(())
73/// ```
74#[must_use]
75pub fn info(date: NepaliDate) -> CalendarInfo {
76    // A date before the coverage wraps to a huge index, so the `get`s below are the only range
77    // check. `days_until` from MIN is never negative.
78    let o = usize::try_from(NepaliDate::MIN.days_until(date))
79        .unwrap_or(usize::MAX)
80        .wrapping_sub(FIRST);
81    let (Some(&tithi), Some(&word), Some(&start), Some(&end)) = (
82        DAY_TITHI.get(o),
83        HOLIDAY_BITS.get(o / 64),
84        DAY_EVENTS.get(o),
85        DAY_EVENTS.get(o.wrapping_add(1)),
86    ) else {
87        return EMPTY;
88    };
89    CalendarInfo {
90        tithi,
91        holiday: (word >> (o % 64)) & 1 == 1,
92        events: (start, end),
93    }
94}
95
96/// The smallest BS range that holds every day with any data: 2077-01-01 to 2084-08-20 with
97/// the data compiled into this version.
98///
99/// ```
100/// let range = nepdate_patro::coverage();
101/// assert_eq!(range.start().to_string(), "2077/01/01");
102/// assert_eq!(range.end().to_string(), "2084/08/20");
103/// ```
104#[must_use]
105pub fn coverage() -> NepaliDateRange {
106    COVERAGE
107}
108
109/// Every public holiday, in ascending date order.
110///
111/// ```
112/// let mut holidays = nepdate_patro::public_holidays();
113/// assert_eq!(holidays.len(), 294);
114/// assert_eq!(holidays.next().map(|d| d.to_string()).as_deref(), Some("2077/01/01"));
115/// assert_eq!(holidays.next_back().map(|d| d.to_string()).as_deref(), Some("2084/08/18"));
116/// ```
117#[must_use]
118pub fn public_holidays() -> impl DoubleEndedIterator<Item = NepaliDate> + ExactSizeIterator {
119    PublicHolidays {
120        offsets: HOLIDAY_OFFSETS.iter(),
121    }
122}
123
124/// The iterator behind [`public_holidays`].
125///
126/// Every offset is inside the coverage (a unit test checks it), so the conversion never fails
127/// and `len` is exact. If it ever did fail, the iterator would end early instead of panicking.
128#[derive(Clone, Debug)]
129struct PublicHolidays {
130    offsets: core::slice::Iter<'static, u16>,
131}
132
133fn holiday_date(o: u16) -> Option<NepaliDate> {
134    let serial = i32::try_from(FIRST + usize::from(o)).ok()?;
135    NepaliDate::MIN.checked_add_days(serial).ok()
136}
137
138impl Iterator for PublicHolidays {
139    type Item = NepaliDate;
140
141    fn next(&mut self) -> Option<NepaliDate> {
142        self.offsets.next().copied().and_then(holiday_date)
143    }
144
145    fn size_hint(&self) -> (usize, Option<usize>) {
146        self.offsets.size_hint()
147    }
148}
149
150impl DoubleEndedIterator for PublicHolidays {
151    fn next_back(&mut self) -> Option<NepaliDate> {
152        self.offsets.next_back().copied().and_then(holiday_date)
153    }
154}
155
156impl ExactSizeIterator for PublicHolidays {}
157
158impl FusedIterator for PublicHolidays {}
159
160/// What the patro says about one day, from [`info`]. Six bytes, `Copy`.
161///
162/// ```
163/// use nepdate::NepaliDate;
164///
165/// let info = nepdate_patro::info(NepaliDate::new(2081, 1, 2)?);
166/// assert!(info.tithi().is_some() && !info.has_events());
167/// # Ok::<(), nepdate::Error>(())
168/// ```
169#[derive(Clone, Copy, Debug)]
170pub struct CalendarInfo {
171    tithi: u8,
172    holiday: bool,
173    events: (u16, u16),
174}
175
176impl CalendarInfo {
177    /// The tithi (lunar day), or `None` outside 2078-01-02 to 2083-12-30.
178    ///
179    /// ```
180    /// use nepdate::{Lang, NepaliDate};
181    ///
182    /// let tithi = nepdate_patro::info(NepaliDate::new(2081, 1, 2)?).tithi();
183    /// assert_eq!(tithi.map(|t| t.name(Lang::En)), Some("Shashthi"));
184    /// assert!(nepdate_patro::info(NepaliDate::new(2078, 1, 1)?).tithi().is_none());
185    /// # Ok::<(), nepdate::Error>(())
186    /// ```
187    #[must_use]
188    pub fn tithi(self) -> Option<Tithi> {
189        if self.tithi == NO_TITHI {
190            None
191        } else {
192            Some(Tithi(self.tithi))
193        }
194    }
195
196    /// Whether the day is a gazetted public holiday.
197    ///
198    /// ```
199    /// use nepdate::NepaliDate;
200    ///
201    /// assert!(nepdate_patro::info(NepaliDate::new(2081, 2, 15)?).is_public_holiday());
202    /// assert!(!nepdate_patro::info(NepaliDate::new(2081, 1, 2)?).is_public_holiday());
203    /// # Ok::<(), nepdate::Error>(())
204    /// ```
205    #[must_use]
206    pub fn is_public_holiday(self) -> bool {
207        self.holiday
208    }
209
210    /// The day's events, in the order of the source data (display order).
211    ///
212    /// ```
213    /// use nepdate::{Lang, NepaliDate};
214    ///
215    /// let info = nepdate_patro::info(NepaliDate::new(2081, 2, 15)?);
216    /// let events: Vec<&str> = info.events().map(|e| e.name(Lang::En)).collect();
217    /// assert_eq!(events, ["Republic Day"]);
218    /// # Ok::<(), nepdate::Error>(())
219    /// ```
220    #[must_use]
221    pub fn events(self) -> Events {
222        Events {
223            front: self.events.0,
224            back: self.events.1,
225        }
226    }
227
228    /// Whether the day has at least one event.
229    ///
230    /// ```
231    /// use nepdate::NepaliDate;
232    ///
233    /// assert!(nepdate_patro::info(NepaliDate::new(2081, 1, 1)?).has_events());
234    /// assert!(!nepdate_patro::info(NepaliDate::new(2081, 1, 2)?).has_events());
235    /// # Ok::<(), nepdate::Error>(())
236    /// ```
237    #[must_use]
238    pub fn has_events(self) -> bool {
239        self.events.0 < self.events.1
240    }
241}
242
243/// The events of one day, from [`CalendarInfo::events`].
244///
245/// ```
246/// use nepdate::NepaliDate;
247///
248/// let events = nepdate_patro::info(NepaliDate::new(2080, 11, 1)?).events();
249/// assert_eq!(events.len(), 5);
250/// # Ok::<(), nepdate::Error>(())
251/// ```
252#[derive(Clone, Debug)]
253pub struct Events {
254    front: u16,
255    back: u16,
256}
257
258impl Iterator for Events {
259    type Item = Event;
260
261    fn next(&mut self) -> Option<Event> {
262        if self.front >= self.back {
263            return None;
264        }
265        let id = *EVENT_IDS.get(usize::from(self.front))?;
266        self.front += 1;
267        Some(Event(id))
268    }
269
270    fn size_hint(&self) -> (usize, Option<usize>) {
271        let n = usize::from(self.back.saturating_sub(self.front));
272        (n, Some(n))
273    }
274}
275
276impl DoubleEndedIterator for Events {
277    fn next_back(&mut self) -> Option<Event> {
278        if self.front >= self.back {
279            return None;
280        }
281        let id = *EVENT_IDS.get(usize::from(self.back - 1))?;
282        self.back -= 1;
283        Some(Event(id))
284    }
285}
286
287impl ExactSizeIterator for Events {}
288
289impl FusedIterator for Events {}
290
291/// A tithi (lunar day), from [`CalendarInfo::tithi`].
292///
293/// ```
294/// use nepdate::{Lang, NepaliDate};
295///
296/// let tithi = nepdate_patro::info(NepaliDate::new(2081, 1, 1)?).tithi();
297/// assert_eq!(tithi.map(|t| t.name(Lang::Ne)), Some("पञ्चमी"));
298/// # Ok::<(), nepdate::Error>(())
299/// ```
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub struct Tithi(u8);
302
303impl Tithi {
304    /// The tithi's name: Devanagari for [`Lang::Ne`], Latin for [`Lang::En`].
305    ///
306    /// ```
307    /// use nepdate::{Lang, NepaliDate};
308    ///
309    /// let tithi = nepdate_patro::info(NepaliDate::new(2080, 11, 1)?).tithi();
310    /// assert_eq!(tithi.map(|t| t.name(Lang::En)), Some("Chaturthi"));
311    /// assert_eq!(tithi.map(|t| t.name(Lang::Ne)), Some("चतुर्थी"));
312    /// # Ok::<(), nepdate::Error>(())
313    /// ```
314    #[must_use]
315    pub fn name(self, lang: Lang) -> &'static str {
316        // A `Tithi` only comes from `DAY_TITHI`, whose entries are all in range (unit test),
317        // so the empty fallback is never returned.
318        TITHI_NAMES
319            .get(usize::from(self.0))
320            .map_or("", |&(np, en)| pick(np, en, lang))
321    }
322}
323
324/// One event of a day, from [`Events`].
325///
326/// ```
327/// use nepdate::{Lang, NepaliDate};
328///
329/// let info = nepdate_patro::info(NepaliDate::new(2081, 4, 15)?);
330/// let second = info.events().nth(1).map(|e| e.name(Lang::En));
331/// assert_eq!(second, Some("World Day Against Human Trafficking"));
332/// # Ok::<(), nepdate::Error>(())
333/// ```
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335pub struct Event(u16);
336
337impl Event {
338    /// The event's name: Devanagari for [`Lang::Ne`], Latin for [`Lang::En`].
339    ///
340    /// ```
341    /// use nepdate::{Lang, NepaliDate};
342    ///
343    /// let info = nepdate_patro::info(NepaliDate::new(2081, 2, 15)?);
344    /// assert_eq!(info.events().next().map(|e| e.name(Lang::En)), Some("Republic Day"));
345    /// # Ok::<(), nepdate::Error>(())
346    /// ```
347    #[must_use]
348    pub fn name(self, lang: Lang) -> &'static str {
349        // An `Event` only comes from `EVENT_IDS`, whose entries are all in range (unit test).
350        NAMES
351            .get(usize::from(self.0))
352            .map_or("", |&(np, en)| pick(np, en, lang))
353    }
354}
355
356fn pick(np: &'static str, en: &'static str, lang: Lang) -> &'static str {
357    match lang {
358        Lang::Ne => np,
359        Lang::En => en,
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    #![allow(clippy::indexing_slicing, clippy::unwrap_used)]
366
367    use super::*;
368
369    #[test]
370    fn table_counts() {
371        assert_eq!(PATRO_DAYS, 2793);
372        let tithi_days = DAY_TITHI.iter().filter(|&&t| t != NO_TITHI).count();
373        assert_eq!(tithi_days, 2190);
374        assert_eq!(HOLIDAY_OFFSETS.len(), 294);
375        let bits: u32 = HOLIDAY_BITS.iter().map(|w| w.count_ones()).sum();
376        assert_eq!(bits, 294);
377        assert_eq!(usize::from(DAY_EVENTS[PATRO_DAYS]), 3032);
378        assert_eq!(EVENT_IDS.len(), 3032);
379        let event_days = DAY_EVENTS.windows(2).filter(|w| w[0] < w[1]).count();
380        assert_eq!(event_days, 1671);
381        assert_eq!(NAMES.len(), 721);
382        assert_eq!(TITHI_NAMES.len(), 32);
383    }
384
385    #[test]
386    fn indices_are_in_range() {
387        assert!(EVENT_IDS.iter().all(|&id| usize::from(id) < NAMES.len()));
388        assert!(
389            DAY_TITHI
390                .iter()
391                .all(|&t| usize::from(t) < TITHI_NAMES.len() || t == NO_TITHI)
392        );
393        assert!(DAY_EVENTS.windows(2).all(|w| w[0] <= w[1]));
394        assert_eq!(DAY_EVENTS[0], 0);
395    }
396
397    #[test]
398    fn holiday_offsets_match_the_bits_and_the_coverage() {
399        assert!(HOLIDAY_OFFSETS.windows(2).all(|w| w[0] < w[1]));
400        for &o in &HOLIDAY_OFFSETS {
401            assert!(holiday_date(o).is_some());
402            let o = usize::from(o);
403            assert!(o < PATRO_DAYS);
404            assert_eq!((HOLIDAY_BITS[o / 64] >> (o % 64)) & 1, 1);
405        }
406        let unused_bits = HOLIDAY_BITS.len() * 64 - PATRO_DAYS;
407        let last_word = HOLIDAY_BITS[HOLIDAY_BITS.len() - 1];
408        assert!(last_word.leading_zeros() as usize >= unused_bits);
409    }
410
411    #[test]
412    fn patro_first_is_the_serial_of_the_coverage_start() {
413        let serial = NepaliDate::MIN.days_until(COVERAGE.start());
414        assert_eq!(usize::try_from(serial).unwrap(), FIRST);
415        assert_eq!(usize::try_from(COVERAGE.len()).unwrap(), PATRO_DAYS);
416    }
417
418    #[test]
419    fn names_are_never_empty() {
420        assert!(
421            NAMES
422                .iter()
423                .chain(&TITHI_NAMES)
424                .all(|(np, en)| !np.is_empty() && !en.is_empty())
425        );
426    }
427
428    #[test]
429    fn info_is_six_bytes() {
430        assert_eq!(core::mem::size_of::<CalendarInfo>(), 6);
431    }
432}