1use std::fmt;
2
3use chrono::{
4 DateTime, Datelike, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc,
5};
6use chrono_tz::{GapInfo, Tz};
7
8use super::rule::{RecurrenceFrequency, RecurrenceRule, last_day_of_month};
9use super::{RecurrenceDuePolicy, RecurrenceSchedule, RecurrenceSlot};
10
11pub fn is_slot(rule: &RecurrenceRule, start_on: NaiveDate, date: NaiveDate) -> bool {
12 rule.matches(start_on, date)
13}
14
15pub fn next_slot_after(
16 rule: &RecurrenceRule,
17 start_on: NaiveDate,
18 date: NaiveDate,
19) -> Option<NaiveDate> {
20 slot_on_or_after(rule, start_on, date.checked_add_days(Days::new(1))?)
21}
22
23pub fn live_slot_on(
24 rule: &RecurrenceRule,
25 start_on: NaiveDate,
26 now: DateTime<Utc>,
27 timezone: &super::TimeZoneId,
28) -> Option<NaiveDate> {
29 let zone = timezone.timezone();
30 let local_today = now.with_timezone(&zone).date_naive();
31 let mut slot = slot_on_or_before(rule, start_on, local_today)?;
32 loop {
33 if resolve_local(zone, slot.and_time(NaiveTime::MIN)).ok()? <= now {
34 return Some(slot);
35 }
36 let before = slot.checked_sub_days(Days::new(1))?;
37 slot = slot_on_or_before(rule, start_on, before)?;
38 }
39}
40
41pub fn projection_slot_at(
42 schedule: &RecurrenceSchedule,
43 at: DateTime<Utc>,
44) -> Result<NaiveDate, RecurrenceScheduleError> {
45 if let Some(slot) = live_slot_on(&schedule.rule, schedule.start_on, at, &schedule.timezone) {
46 return Ok(slot);
47 }
48 schedule
49 .slots_on_or_after(schedule.start_on)
50 .next()
51 .ok_or(RecurrenceScheduleError::DateOverflow)
52}
53
54pub fn slot_cutoff(
55 schedule: &RecurrenceSchedule,
56 slot_on: NaiveDate,
57) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
58 require_slot(schedule, slot_on)?;
59 let next = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
60 .ok_or(RecurrenceScheduleError::DateOverflow)?;
61 resolve_local(schedule.timezone.timezone(), next.and_time(NaiveTime::MIN))
62}
63
64pub fn slot_values(
65 schedule: &RecurrenceSchedule,
66 slot_on: NaiveDate,
67) -> Result<RecurrenceSlot, RecurrenceScheduleError> {
68 require_slot(schedule, slot_on)?;
69 let zone = schedule.timezone.timezone();
70 let boundary = resolve_local(zone, slot_on.and_time(NaiveTime::MIN))?;
71 let available_local_time = schedule.available_local_time.unwrap_or(NaiveTime::MIN);
72 let available_at = resolve_local(zone, slot_on.and_time(available_local_time))?;
73 let due_on = match schedule.due_policy {
74 RecurrenceDuePolicy::SameDay => Some(slot_on.format("%Y-%m-%d").to_string()),
75 RecurrenceDuePolicy::None => None,
76 };
77 Ok(RecurrenceSlot {
78 scheduled_on: slot_on,
79 boundary_at: format_utc(boundary),
80 available_at: format_utc(available_at),
81 due_on,
82 })
83}
84
85#[derive(Debug, Clone)]
86pub struct RecurrenceSlotIter<'a> {
87 rule: &'a RecurrenceRule,
88 start_on: NaiveDate,
89 candidate: Option<NaiveDate>,
90}
91
92impl<'a> RecurrenceSlotIter<'a> {
93 pub(crate) fn new(
94 rule: &'a RecurrenceRule,
95 start_on: NaiveDate,
96 on_or_after: NaiveDate,
97 ) -> Self {
98 Self {
99 rule,
100 start_on,
101 candidate: Some(on_or_after.max(start_on)),
102 }
103 }
104}
105
106impl Iterator for RecurrenceSlotIter<'_> {
107 type Item = NaiveDate;
108
109 fn next(&mut self) -> Option<Self::Item> {
110 let slot = slot_on_or_after(self.rule, self.start_on, self.candidate?)?;
111 self.candidate = slot.checked_add_days(Days::new(1));
112 Some(slot)
113 }
114}
115
116pub(crate) fn slot_on_or_after(
117 rule: &RecurrenceRule,
118 start_on: NaiveDate,
119 date: NaiveDate,
120) -> Option<NaiveDate> {
121 let target = date.max(start_on);
122 match rule.frequency() {
123 RecurrenceFrequency::Daily => daily_slot_on_or_after(rule.interval(), start_on, target),
124 RecurrenceFrequency::Weekly => weekly_slot_on_or_after(rule, start_on, target),
125 RecurrenceFrequency::Monthly => {
126 month_slot_on_or_after(i64::from(rule.interval()), start_on, target)
127 }
128 RecurrenceFrequency::Yearly => {
129 month_slot_on_or_after(i64::from(rule.interval()) * 12, start_on, target)
130 }
131 }
132}
133
134pub(crate) fn slot_on_or_before(
135 rule: &RecurrenceRule,
136 start_on: NaiveDate,
137 date: NaiveDate,
138) -> Option<NaiveDate> {
139 if date < start_on {
140 return None;
141 }
142 match rule.frequency() {
143 RecurrenceFrequency::Daily => daily_slot_on_or_before(rule.interval(), start_on, date),
144 RecurrenceFrequency::Weekly => weekly_slot_on_or_before(rule, start_on, date),
145 RecurrenceFrequency::Monthly => {
146 month_slot_on_or_before(i64::from(rule.interval()), start_on, date)
147 }
148 RecurrenceFrequency::Yearly => {
149 month_slot_on_or_before(i64::from(rule.interval()) * 12, start_on, date)
150 }
151 }
152}
153
154fn daily_slot_on_or_after(
155 interval: u32,
156 start_on: NaiveDate,
157 date: NaiveDate,
158) -> Option<NaiveDate> {
159 let elapsed = date.signed_duration_since(start_on).num_days();
160 let interval = i64::from(interval);
161 let periods = elapsed.checked_add(interval - 1)?.checked_div(interval)?;
162 add_days(start_on, periods.checked_mul(interval)?)
163}
164
165fn daily_slot_on_or_before(
166 interval: u32,
167 start_on: NaiveDate,
168 date: NaiveDate,
169) -> Option<NaiveDate> {
170 let elapsed = date.signed_duration_since(start_on).num_days();
171 let interval = i64::from(interval);
172 add_days(
173 start_on,
174 elapsed.checked_div(interval)?.checked_mul(interval)?,
175 )
176}
177
178fn month_slot_on_or_after(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
179 let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
180 let mut index = difference.checked_div(period)?;
181 let mut slot = month_slot(start_on, period, index)?;
182 if slot < date {
183 index = index.checked_add(1)?;
184 slot = month_slot(start_on, period, index)?;
185 }
186 Some(slot)
187}
188
189fn month_slot_on_or_before(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
190 let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
191 let mut index = difference.checked_div(period)?;
192 let mut slot = month_slot(start_on, period, index)?;
193 if slot > date {
194 index = index.checked_sub(1)?;
195 if index < 0 {
196 return None;
197 }
198 slot = month_slot(start_on, period, index)?;
199 }
200 Some(slot)
201}
202
203fn month_slot(start_on: NaiveDate, period: i64, index: i64) -> Option<NaiveDate> {
204 let ordinal = month_ordinal(start_on).checked_add(period.checked_mul(index)?)?;
205 let year = i32::try_from(ordinal.div_euclid(12)).ok()?;
206 let month = u32::try_from(ordinal.rem_euclid(12) + 1).ok()?;
207 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
208 NaiveDate::from_ymd_opt(year, month, start_on.day().min(last_day_of_month(first)))
209}
210
211fn weekly_slot_on_or_after(
212 rule: &RecurrenceRule,
213 start_on: NaiveDate,
214 date: NaiveDate,
215) -> Option<NaiveDate> {
216 let anchor_monday = monday_of(start_on)?;
217 let target_monday = monday_of(date)?;
218 let target_week = target_monday
219 .signed_duration_since(anchor_monday)
220 .num_days()
221 .checked_div(7)?;
222 let interval = i64::from(rule.interval());
223 let mut block = target_week.checked_div(interval)?;
224 loop {
225 let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
226 for weekday in rule.weekdays_set().iter() {
227 let candidate = add_days(monday, i64::from(weekday.num_days_from_monday()))?;
228 if candidate >= start_on && candidate >= date {
229 return Some(candidate);
230 }
231 }
232 block = block.checked_add(1)?;
233 }
234}
235
236fn weekly_slot_on_or_before(
237 rule: &RecurrenceRule,
238 start_on: NaiveDate,
239 date: NaiveDate,
240) -> Option<NaiveDate> {
241 let anchor_monday = monday_of(start_on)?;
242 let target_monday = monday_of(date)?;
243 let target_week = target_monday
244 .signed_duration_since(anchor_monday)
245 .num_days()
246 .checked_div(7)?;
247 let interval = i64::from(rule.interval());
248 let mut block = target_week.checked_div(interval)?;
249 loop {
250 let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
251 for weekday in rule.weekdays_set().iter().rev() {
252 if let Some(candidate) = add_days(monday, i64::from(weekday.num_days_from_monday()))
253 && candidate >= start_on
254 && candidate <= date
255 {
256 return Some(candidate);
257 }
258 }
259 block = block.checked_sub(1)?;
260 if block < 0 {
261 return None;
262 }
263 }
264}
265
266fn month_ordinal(date: NaiveDate) -> i64 {
267 i64::from(date.year()) * 12 + i64::from(date.month0())
268}
269
270fn monday_of(date: NaiveDate) -> Option<NaiveDate> {
271 date.checked_sub_days(Days::new(u64::from(date.weekday().num_days_from_monday())))
272}
273
274fn add_weeks(date: NaiveDate, weeks: i64) -> Option<NaiveDate> {
275 add_days(date, weeks.checked_mul(7)?)
276}
277
278fn add_days(date: NaiveDate, days: i64) -> Option<NaiveDate> {
279 date.checked_add_days(Days::new(u64::try_from(days).ok()?))
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum RecurrenceScheduleError {
284 NotScheduledSlot,
285 DateOverflow,
286 UnresolvableLocalTime,
287}
288
289impl fmt::Display for RecurrenceScheduleError {
290 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291 formatter.write_str(match self {
292 Self::NotScheduledSlot => "date is not a slot in the recurrence schedule",
293 Self::DateOverflow => "recurrence schedule exceeds the supported date range",
294 Self::UnresolvableLocalTime => {
295 "recurrence local time is outside the time-zone rule range"
296 }
297 })
298 }
299}
300
301impl std::error::Error for RecurrenceScheduleError {}
302
303fn require_slot(
304 schedule: &RecurrenceSchedule,
305 slot_on: NaiveDate,
306) -> Result<(), RecurrenceScheduleError> {
307 if is_slot(&schedule.rule, schedule.start_on, slot_on) {
308 Ok(())
309 } else {
310 Err(RecurrenceScheduleError::NotScheduledSlot)
311 }
312}
313
314fn resolve_local(zone: Tz, local: NaiveDateTime) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
315 let zoned = match zone.from_local_datetime(&local) {
316 LocalResult::Single(value) => value,
317 LocalResult::Ambiguous(first, second) => first.min(second),
318 LocalResult::None => GapInfo::new(&local, &zone)
319 .and_then(|gap| gap.end)
320 .ok_or(RecurrenceScheduleError::UnresolvableLocalTime)?,
321 };
322 Ok(zoned.with_timezone(&Utc))
323}
324
325fn format_utc(value: DateTime<Utc>) -> String {
326 value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
327}