//! Recurring periods and the `next_occurrence_date` of a template (SPEC §9.5). //! //! Pure calendar arithmetic on `YYYY-MM` periods and `YYYY-MM-DD` dates; no clock and no time zone. The occurrence //! *instant* (the clamped date at 06:00 Europe/Stockholm) is the server's concern; this module only fixes which //! period and which calendar date. use std::fmt; use std::str::FromStr; use serde::{Deserialize, Serialize}; /// A month, `YYYY-MM`. Orders chronologically. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Period { year: u16, month: u8, } /// Why a recurrence input is malformed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RecurrenceError { /// A period that is not `YYYY-MM` with month 01 to 12 (years 0001 to 9999). PeriodInvalid, /// `day_of_month` outside 1 to 31 (SPEC §9.6). DayOfMonthOutOfRange, } impl fmt::Display for RecurrenceError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::PeriodInvalid => "period_invalid", Self::DayOfMonthOutOfRange => "day_of_month_out_of_range", }) } } impl std::error::Error for RecurrenceError {} impl Period { /// A period, if `month` is 1 to 12 and `year` 1 to 9999. pub fn new(year: u16, month: u8) -> Option { ((1..=9999).contains(&year) && (1..=12).contains(&month)).then_some(Self { year, month }) } pub fn year(self) -> u16 { self.year } pub fn month(self) -> u8 { self.month } /// The following month, or `None` after 9999-12. pub fn next(self) -> Option { if self.month == 12 { Self::new(self.year.checked_add(1)?, 1) } else { Self::new(self.year, self.month + 1) } } /// Number of days in this month (Gregorian leap years). pub fn days(self) -> u8 { match self.month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, _ if is_leap(self.year) => 29, _ => 28, } } /// The occurrence date of `day_of_month` in this month, clamped to its last day (`31` in April is `30`), as /// `YYYY-MM-DD`. pub fn clamped_date(self, day_of_month: u8) -> Result { if !(1..=31).contains(&day_of_month) { return Err(RecurrenceError::DayOfMonthOutOfRange); } Ok(format!("{self}-{:02}", day_of_month.min(self.days()))) } } impl fmt::Display for Period { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:04}-{:02}", self.year, self.month) } } impl FromStr for Period { type Err = RecurrenceError; fn from_str(s: &str) -> Result { let bytes = s.as_bytes(); let digits = |r: std::ops::Range| bytes[r].iter().all(u8::is_ascii_digit); if bytes.len() != 7 || bytes[4] != b'-' || !digits(0..4) || !digits(5..7) { return Err(RecurrenceError::PeriodInvalid); } let year: u16 = s[0..4] .parse() .map_err(|_| RecurrenceError::PeriodInvalid)?; let month: u8 = s[5..7] .parse() .map_err(|_| RecurrenceError::PeriodInvalid)?; Self::new(year, month).ok_or(RecurrenceError::PeriodInvalid) } } fn is_leap(year: u16) -> bool { (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) } /// The template fields `next_occurrence_date` depends on. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecurrenceSchedule { pub day_of_month: u8, pub start_period: String, pub end_period: Option, pub last_generated_period: Option, pub paused: bool, pub deleted: bool, } /// The first period that may still be generated: the month after `last_generated_period`, but not before /// `start_period`; `None` when that is after `end_period` (SPEC §9.5). Paused and deleted are not considered here. pub fn next_candidate_period( start_period: &str, end_period: Option<&str>, last_generated_period: Option<&str>, ) -> Result, RecurrenceError> { let start: Period = start_period.parse()?; let end: Option = end_period.map(str::parse).transpose()?; let after_last: Option = match last_generated_period { Some(last) => match last.parse::()?.next() { Some(p) => Some(p), None => return Ok(None), }, None => None, }; let candidate = after_last.map_or(start, |p| p.max(start)); Ok(match end { Some(end) if candidate > end => None, _ => Some(candidate), }) } /// `next_occurrence_date` of the RecurringTemplate wire form (SPEC §9.5): the clamped date of the first candidate /// period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `None` when paused, deleted or /// past `end_period`. pub fn next_occurrence_date( schedule: &RecurrenceSchedule, ) -> Result, RecurrenceError> { if !(1..=31).contains(&schedule.day_of_month) { return Err(RecurrenceError::DayOfMonthOutOfRange); } let candidate = next_candidate_period( &schedule.start_period, schedule.end_period.as_deref(), schedule.last_generated_period.as_deref(), )?; if schedule.paused || schedule.deleted { return Ok(None); } candidate .map(|p| p.clamped_date(schedule.day_of_month)) .transpose() } #[cfg(test)] mod tests { use super::*; fn schedule(day: u8, start: &str, end: Option<&str>, last: Option<&str>) -> RecurrenceSchedule { RecurrenceSchedule { day_of_month: day, start_period: start.to_owned(), end_period: end.map(str::to_owned), last_generated_period: last.map(str::to_owned), paused: false, deleted: false, } } fn next(s: &RecurrenceSchedule) -> Option { next_occurrence_date(s).unwrap() } #[test] fn never_generated_starts_at_start_period() { assert_eq!( next(&schedule(25, "2026-11", None, None)).as_deref(), Some("2026-11-25") ); } #[test] fn after_last_generated_period() { assert_eq!( next(&schedule(1, "2026-01", None, Some("2026-10"))).as_deref(), Some("2026-11-01") ); } #[test] fn year_boundary() { assert_eq!( next(&schedule(15, "2026-01", None, Some("2026-12"))).as_deref(), Some("2027-01-15") ); } #[test] fn start_period_moved_past_last_generated() { // Changing start_period never regenerates past periods; a later start wins over last + 1. assert_eq!( next(&schedule(10, "2027-03", None, Some("2026-10"))).as_deref(), Some("2027-03-10") ); } #[test] fn clamped_to_month_end() { assert_eq!( next(&schedule(31, "2026-04", None, None)).as_deref(), Some("2026-04-30") ); assert_eq!( next(&schedule(31, "2027-02", None, None)).as_deref(), Some("2027-02-28") ); assert_eq!( next(&schedule(30, "2028-02", None, None)).as_deref(), Some("2028-02-29") ); assert_eq!( next(&schedule(29, "2100-02", None, None)).as_deref(), Some("2100-02-28") ); assert_eq!( next(&schedule(29, "2000-02", None, None)).as_deref(), Some("2000-02-29") ); } #[test] fn end_period_inclusive_and_past() { assert_eq!( next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-10"))).as_deref(), Some("2026-11-05") ); assert_eq!( next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-11"))), None ); assert_eq!(next(&schedule(5, "2026-12", Some("2026-11"), None)), None); } #[test] fn paused_or_deleted_is_none() { let mut s = schedule(5, "2026-01", None, None); s.paused = true; assert_eq!(next(&s), None); s.paused = false; s.deleted = true; assert_eq!(next(&s), None); } #[test] fn malformed_input() { let bad = |s: RecurrenceSchedule, e| assert_eq!(next_occurrence_date(&s), Err(e)); bad( schedule(0, "2026-01", None, None), RecurrenceError::DayOfMonthOutOfRange, ); bad( schedule(32, "2026-01", None, None), RecurrenceError::DayOfMonthOutOfRange, ); bad( schedule(1, "2026-13", None, None), RecurrenceError::PeriodInvalid, ); bad( schedule(1, "2026-1", None, None), RecurrenceError::PeriodInvalid, ); bad( schedule(1, "2026-01", Some("x"), None), RecurrenceError::PeriodInvalid, ); bad( schedule(1, "2026-01", None, Some("2026-00")), RecurrenceError::PeriodInvalid, ); } #[test] fn period_round_trip_and_order() { let p: Period = "2026-10".parse().unwrap(); assert_eq!(p.to_string(), "2026-10"); assert!(p < "2026-11".parse().unwrap()); assert!(p > "2025-12".parse().unwrap()); assert_eq!(p.next().map(|n| n.to_string()).as_deref(), Some("2026-11")); assert_eq!("9999-12".parse::().unwrap().next(), None); } }