Skip to main content

kosher_rust/limudim/
pirkei_avos.rs

1use icu_calendar::{
2    Date,
3    cal::Hebrew,
4    types::{Month, Weekday},
5};
6
7use crate::{
8    calendar::{
9        HebrewHolidayCalendar,
10        month::{AV, NISAN, SIVAN, TISHREI},
11    },
12    limudim::{
13        HebrewDateExt, Limud,
14        cycle::Cycle,
15        interval::Interval,
16        limud::{CycleFinder, InternalLimud},
17    },
18};
19
20#[allow(clippy::expect_used)]
21fn from_hebrew_date(year: i32, month: Month, day: u8) -> Date<Hebrew> {
22    Date::try_new_hebrew_v2(year, month, day).expect("hard-coded Hebrew date should be valid")
23}
24
25fn day_of_week_number(date: Date<Hebrew>) -> i32 {
26    match date.weekday() {
27        Weekday::Sunday => 1,
28        Weekday::Monday => 2,
29        Weekday::Tuesday => 3,
30        Weekday::Wednesday => 4,
31        Weekday::Thursday => 5,
32        Weekday::Friday => 6,
33        Weekday::Saturday => 7,
34    }
35}
36
37/// Represents a Pirkei Avos reading unit
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39#[cfg_attr(feature = "defmt", derive(defmt::Format))]
40pub enum PirkeiAvosUnit {
41    /// A single perek (chapter)
42    Single(u8),
43    /// Two consecutive perekim (chapters)
44    Combined(u8, u8),
45}
46
47#[derive(Default)]
48/// Calculates the Pirkei Avos schedule.
49pub struct PirkeiAvos {
50    /// Whether the calculator is for Israel or the diaspora
51    pub in_israel: bool,
52}
53
54impl InternalLimud<PirkeiAvosUnit> for PirkeiAvos {
55    fn cycle_finder(&self) -> CycleFinder {
56        if self.in_israel {
57            CycleFinder::Perpetual(Self::find_yearly_cycle_israel)
58        } else {
59            CycleFinder::Perpetual(Self::find_yearly_cycle_diaspora)
60        }
61    }
62
63    fn unit_for_interval(&self, interval: &Interval, _limud_date: &Date<Hebrew>) -> Option<PirkeiAvosUnit> {
64        let iteration = interval.iteration;
65
66        // First 18 weeks: standard 1-6 cycle repeated 3 times
67        if iteration < 19 {
68            let chapter = ((iteration - 1) % 6) + 1;
69            return Some(PirkeiAvosUnit::Single(chapter as u8));
70        }
71
72        // Fourth round: use weeks remaining logic (like hebcal)
73        // Calculate weeks remaining from this interval's Shabbat to the end of the cycle
74        let days_until_end = interval.end_date.days_until(&interval.cycle.end_date)?;
75        let weeks_remain = days_until_end.div_ceil(7);
76
77        match weeks_remain {
78            0 => Some(PirkeiAvosUnit::Combined(5, 6)),
79            1 => Some(PirkeiAvosUnit::Combined(3, 4)),
80            2 => {
81                // If iteration % 6 == 1, return [2], else [1,2]
82                if (iteration - 1) % 6 == 0 {
83                    Some(PirkeiAvosUnit::Combined(1, 2))
84                } else {
85                    Some(PirkeiAvosUnit::Single(((iteration - 1) % 6 + 1) as u8))
86                }
87            }
88            3 => Some(PirkeiAvosUnit::Single(1)),
89            _ => {
90                // Continue normal cycle for weeks > 3 remaining
91                let chapter = ((iteration - 1) % 6) + 1;
92                Some(PirkeiAvosUnit::Single(chapter as u8))
93            }
94        }
95    }
96    fn interval_end_calculation(_cycle: Cycle, hebrew_date: Date<Hebrew>) -> Option<Date<Hebrew>> {
97        // Each interval is a week, ending on Shabbos
98        let day_number = day_of_week_number(hebrew_date);
99        hebrew_date.add_days(7 - day_number)
100    }
101
102    fn is_skip_interval(&self, interval: &Interval) -> bool {
103        let end_month = interval.end_date.input_month();
104        let end_day = interval.end_date.day_of_month().0;
105
106        // Skip erev Tisha B'Av (8th of Av) - applies to both Israel and diaspora
107        if end_month == AV && end_day == 8 {
108            return true;
109        }
110
111        // Skip Tisha B'Av (9th of Av) - applies to both Israel and diaspora
112        if end_month == AV && end_day == 9 {
113            return true;
114        }
115
116        // Skip 7th of Sivan (2nd day Shavuot) - only outside Israel
117        if !self.in_israel && end_month == SIVAN && end_day == 7 {
118            return true;
119        }
120
121        false
122    }
123}
124impl Limud<PirkeiAvosUnit> for PirkeiAvos {}
125
126impl PirkeiAvos {
127    /// Create a new Pirkei Avos calculator.
128    ///
129    /// # Arguments
130    /// * `in_israel` - Whether the calculator is for Israel or the diaspora
131    ///
132    /// # Returns
133    /// A new Pirkei Avos calculator.
134    pub fn new(in_israel: bool) -> Self {
135        Self { in_israel }
136    }
137
138    fn find_yearly_cycle_israel(date: Date<Hebrew>) -> Option<(Date<Hebrew>, Date<Hebrew>)> {
139        Some(Self::find_yearly_cycle(true, date))
140    }
141
142    fn find_yearly_cycle_diaspora(date: Date<Hebrew>) -> Option<(Date<Hebrew>, Date<Hebrew>)> {
143        Some(Self::find_yearly_cycle(false, date))
144    }
145
146    /// Find the Pirkei Avos cycle for a given date.
147    /// Cycle starts the day after Pesach (Nissan 22 in Israel, Nissan 23 outside)
148    /// and ends on the last Shabbos before Rosh Hashanah.
149    fn find_yearly_cycle(in_israel: bool, date: Date<Hebrew>) -> (Date<Hebrew>, Date<Hebrew>) {
150        let year = date.year().extended_year();
151
152        // Day after Pesach: Nissan 22 in Israel, Nissan 23 outside
153        let anchor_day = if in_israel { 22 } else { 23 };
154        let cycle_start_this_year = from_hebrew_date(year, NISAN, anchor_day);
155
156        // Determine which year's cycle we're in
157        let (start_date, cycle_year) = if date >= cycle_start_this_year {
158            (cycle_start_this_year, year)
159        } else {
160            // We're before this year's cycle starts, use previous year's cycle
161            let prev_year_start = from_hebrew_date(year - 1, NISAN, anchor_day);
162            (prev_year_start, year - 1)
163        };
164
165        // End date: last Shabbos before Rosh Hashanah of the following year
166        let rosh_hashana = from_hebrew_date(cycle_year + 1, TISHREI, 1);
167        let day_number = day_of_week_number(rosh_hashana);
168        // Subtract days to get to the previous Shabbos
169        let end_date = rosh_hashana.add_days(-day_number).unwrap_or(rosh_hashana);
170
171        (start_date, end_date)
172    }
173}
174
175#[cfg(test)]
176#[allow(clippy::expect_used)]
177mod tests {
178    use crate::calendar::month::ELUL;
179
180    use super::*;
181
182    // Test cases based on Python test_pirkei_avos_calculator.py
183
184    #[test]
185    fn test_simple_date() {
186        // JewishDate(5778, 3, 1) - 1st of Sivan 5778
187        let test_date = from_hebrew_date(5778, SIVAN, 1);
188        let calculator = PirkeiAvos::new(false);
189        let limud = calculator.limud(test_date).expect("limud exists");
190        // Python test expects description '6'
191        assert_eq!(limud, PirkeiAvosUnit::Single(6));
192    }
193
194    #[test]
195    fn test_near_end_of_cycle() {
196        // JewishDate(5778, 6, 20) - 20th of Elul 5778
197        let test_date = from_hebrew_date(5778, ELUL, 20);
198        let calculator = PirkeiAvos::new(false);
199        let limud = calculator.limud(test_date).expect("limud exists");
200        // Python test expects description '3 - 4'
201        assert_eq!(limud, PirkeiAvosUnit::Combined(3, 4));
202    }
203
204    #[test]
205    fn test_after_cycle_completes() {
206        // JewishDate(5778, 6, 29) - 29th of Elul 5778
207        let test_date = from_hebrew_date(5778, ELUL, 29);
208        let calculator = PirkeiAvos::new(false);
209        let limud = calculator.limud(test_date);
210        assert!(limud.is_none());
211    }
212
213    #[test]
214    fn test_before_cycle_starts() {
215        // JewishDate(5778, 1, 20) - 20th of Nissan 5778
216        let test_date = from_hebrew_date(5778, NISAN, 20);
217        let calculator = PirkeiAvos::new(false);
218        let limud = calculator.limud(test_date);
219        assert!(limud.is_none());
220    }
221
222    #[test]
223    fn test_8th_day_pesach_outside_israel() {
224        // JewishDate(5778, 1, 22) - 22nd of Nissan 5778
225        let test_date = from_hebrew_date(5778, NISAN, 22);
226        let calculator = PirkeiAvos::new(false);
227        let limud = calculator.limud(test_date);
228        assert!(limud.is_none());
229    }
230
231    #[test]
232    fn test_day_after_pesach_outside_israel() {
233        // JewishDate(5778, 1, 23) - 23rd of Nissan 5778
234        let test_date = from_hebrew_date(5778, NISAN, 23);
235        let calculator = PirkeiAvos::new(false);
236        let limud = calculator.limud(test_date).expect("limud exists");
237        // Python test expects description '1'
238        assert_eq!(limud, PirkeiAvosUnit::Single(1));
239    }
240
241    #[test]
242    fn test_compounding_before_cycle_end_outside_israel() {
243        // JewishDate(5778, 6, 14) - 14th of Elul 5778
244        let test_date = from_hebrew_date(5778, ELUL, 14);
245        let calculator = PirkeiAvos::new(false);
246        let limud = calculator.limud(test_date).expect("limud exists");
247        assert_eq!(limud, PirkeiAvosUnit::Combined(1, 2));
248
249        // JewishDate(5778, 6, 15) - 15th of Elul 5778
250        let test_date2 = from_hebrew_date(5778, ELUL, 15);
251        let limud2 = calculator.limud(test_date2).expect("limud exists");
252        assert_eq!(limud2, PirkeiAvosUnit::Combined(3, 4));
253    }
254
255    #[test]
256    fn test_8th_day_pesach_in_israel() {
257        // JewishDate(5778, 1, 22) - 22nd of Nissan 5778 (Shabbos)
258        let test_date = from_hebrew_date(5778, NISAN, 22);
259        let calculator = PirkeiAvos::new(true);
260        let limud = calculator.limud(test_date).expect("limud exists");
261        // In Israel, cycle starts on 22nd, and if it's Shabbos, that's the first interval
262        assert_eq!(limud, PirkeiAvosUnit::Single(1));
263    }
264
265    #[test]
266    fn test_day_after_pesach_in_israel() {
267        // JewishDate(5778, 1, 23) - 23rd of Nissan 5778
268        let test_date = from_hebrew_date(5778, NISAN, 23);
269        let calculator = PirkeiAvos::new(true);
270        let limud = calculator.limud(test_date).expect("limud exists");
271        // Python test expects description '2'
272        assert_eq!(limud, PirkeiAvosUnit::Single(2));
273    }
274
275    #[test]
276    fn test_compounding_before_cycle_end_in_israel() {
277        // JewishDate(5778, 6, 21) - 21st of Elul 5778
278        let test_date = from_hebrew_date(5778, ELUL, 21);
279        let calculator = PirkeiAvos::new(true);
280        let limud = calculator.limud(test_date).expect("limud exists");
281        assert_eq!(limud, PirkeiAvosUnit::Combined(3, 4));
282    }
283
284    #[test]
285    fn test_7_sivan_on_shabbos_outside_israel() {
286        // 5769 - Sivan 7 falls on Shabbos outside Israel
287        // JewishDate(5769, 3, 3) - 3rd of Sivan 5769
288        let test_date = from_hebrew_date(5769, SIVAN, 3);
289        let calculator = PirkeiAvos::new(false);
290        let limud = calculator.limud(test_date);
291        // This interval should be skipped, returning None for the unit
292        // The Python test expects limud to exist but with None unit
293        // In our implementation, we just don't return a unit for skipped intervals
294        assert!(limud.is_none());
295    }
296
297    #[test]
298    fn test_iteration_following_7_sivan_on_shabbos_outside_israel() {
299        // JewishDate(5769, 3, 8) - 8th of Sivan 5769
300        let test_date = from_hebrew_date(5769, SIVAN, 8);
301        let calculator = PirkeiAvos::new(false);
302        let limud = calculator.limud(test_date).expect("limud exists");
303        // Python test expects description '1' (starts new sub-cycle)
304        assert_eq!(limud, PirkeiAvosUnit::Single(1));
305    }
306
307    #[test]
308    fn test_7_sivan_on_shabbos_in_israel() {
309        // JewishDate(5769, 3, 3) - 3rd of Sivan 5769
310        let test_date = from_hebrew_date(5769, SIVAN, 3);
311        let calculator = PirkeiAvos::new(true);
312        let limud = calculator.limud(test_date).expect("limud exists");
313        // In Israel, no skip - Python test expects description '1'
314        assert_eq!(limud, PirkeiAvosUnit::Single(1));
315    }
316
317    #[test]
318    fn test_iteration_following_7_sivan_on_shabbos_in_israel() {
319        // JewishDate(5769, 3, 8) - 8th of Sivan 5769
320        let test_date = from_hebrew_date(5769, SIVAN, 8);
321        let calculator = PirkeiAvos::new(true);
322        let limud = calculator.limud(test_date).expect("limud exists");
323        // Python test expects description '2'
324        assert_eq!(limud, PirkeiAvosUnit::Single(2));
325    }
326}