Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_datime/src/schedule/recurrence.rs

19.4 KiB, 73 runs

created by r1870400018:8494, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Recurrence patterns and rules for recurring tasks.
2//!
3//! A pattern says how often. A rule wraps a pattern with the dates it must
4//! skip, the dates it must keep, and whether the business calendar applies.
5//!
6//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
7//! Anthropic Claude
8
9use oxedyne_fe2o3_core::prelude::*;
10use crate::{
11 time::{CalClock, CalClockZone},
12 constant::{DayOfWeek, MonthOfYear},
13 calendar::Calendar,
14};
15use std::collections::HashSet;
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum RecurrencePattern {
19 EveryMinutes(u32),
20 EveryHours(u32),
21 Daily, // same time each day
22 EveryDays(u32),
23 Weekly(HashSet<DayOfWeek>), // the days it lands on
24 EveryWeeks(u32), // same weekday
25 Monthly(u8), // day of month, 1-31
26 EveryMonths(u32),
27 Yearly(HashSet<MonthOfYear>), // the months it lands on
28 BusinessDaily, // weekends and holidays skipped
29 MonthlyWeekday {
30 week: WeekOccurrence,
31 day: DayOfWeek,
32 },
33 Cron(CronExpression),
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub enum WeekOccurrence {
38 First,
39 Second,
40 Third,
41 Fourth,
42 Last,
43}
44
45impl RecurrencePattern {
46 pub fn next_execution(&self, current_time: &CalClock, zone: &CalClockZone) -> Outcome<Option<CalClock>> {
47 match self {
48 RecurrencePattern::EveryMinutes(minutes) => {
49 let next = res!(current_time.add_minutes(*minutes as i32));
50 Ok(Some(next))
51 },
52 RecurrencePattern::EveryHours(hours) => {
53 let next = res!(current_time.add_hours(*hours as i32));
54 Ok(Some(next))
55 },
56 RecurrencePattern::Daily => {
57 let next = res!(current_time.add_days(1));
58 Ok(Some(next))
59 },
60 RecurrencePattern::EveryDays(days) => {
61 let next = res!(current_time.add_days(*days as i32));
62 Ok(Some(next))
63 },
64 RecurrencePattern::Weekly(days) => {
65 self.next_weekly_execution(current_time, days)
66 },
67 RecurrencePattern::EveryWeeks(weeks) => {
68 let next = res!(current_time.add_days((*weeks as i32) * 7));
69 Ok(Some(next))
70 },
71 RecurrencePattern::Monthly(day) => {
72 self.next_monthly_execution(current_time, *day)
73 },
74 RecurrencePattern::EveryMonths(months) => {
75 let next = res!(current_time.add_months(*months as i32));
76 Ok(Some(next))
77 },
78 RecurrencePattern::Yearly(months) => {
79 self.next_yearly_execution(current_time, months)
80 },
81 RecurrencePattern::BusinessDaily => {
82 self.next_business_day_execution(current_time, zone)
83 },
84 RecurrencePattern::MonthlyWeekday { week, day } => {
85 self.next_monthly_weekday_execution(current_time, week, day, zone)
86 },
87 RecurrencePattern::Cron(cron) => {
88 cron.next_execution(current_time)
89 },
90 }
91 }
92
93 fn next_weekly_execution(&self, current_time: &CalClock, target_days: &HashSet<DayOfWeek>) -> Outcome<Option<CalClock>> {
94 let current_day = current_time.day_of_week();
95 let current_day_num = current_day.of() as i32;
96
97 // Find the next occurrence in this week
98 for day_offset in 1..=7 {
99 let next_day_num = (current_day_num + day_offset - 1) % 7 + 1;
100 if let Ok(next_day) = DayOfWeek::from_number(next_day_num as u8) {
101 if target_days.contains(&next_day) {
102 let next_time = res!(current_time.add_days(day_offset));
103 return Ok(Some(next_time));
104 }
105 }
106 }
107
108 Ok(None) // Should not happen if target_days is not empty
109 }
110
111 fn next_monthly_execution(&self, current_time: &CalClock, target_day: u8) -> Outcome<Option<CalClock>> {
112 let current_day = current_time.day();
113 let current_month = current_time.month();
114 let current_year = current_time.year();
115
116 // Try current month first
117 if target_day > current_day {
118 if let Ok(next_time) = CalClock::new(
119 current_year, current_month, target_day,
120 current_time.hour(), current_time.minute(), current_time.second(), current_time.nanosecond(),
121 current_time.zone().clone()
122 ) {
123 return Ok(Some(next_time));
124 }
125 }
126
127 // Move to next month
128 let (next_year, next_month) = if current_month == 12 {
129 (current_year + 1, 1)
130 } else {
131 (current_year, current_month + 1)
132 };
133
134 // Find valid day in next month (handle month-end edge cases)
135 let month_enum = res!(MonthOfYear::from_number(next_month));
136 let days_in_month = month_enum.days_in_month(next_year);
137 let actual_day = std::cmp::min(target_day, days_in_month);
138
139 let next_time = res!(CalClock::new(
140 next_year, next_month, actual_day,
141 current_time.hour(), current_time.minute(), current_time.second(), current_time.nanosecond(),
142 current_time.zone().clone()
143 ));
144
145 Ok(Some(next_time))
146 }
147
148 fn next_yearly_execution(&self, current_time: &CalClock, target_months: &HashSet<MonthOfYear>) -> Outcome<Option<CalClock>> {
149 let _current_month = current_time.month_of_year();
150 let current_year = current_time.year();
151
152 // Try remaining months in current year
153 for month_num in (current_time.month() + 1)..=12 {
154 if let Ok(month) = MonthOfYear::from_number(month_num) {
155 if target_months.contains(&month) {
156 let next_time = res!(CalClock::new(
157 current_year, month_num, current_time.day(),
158 current_time.hour(), current_time.minute(), current_time.second(), current_time.nanosecond(),
159 current_time.zone().clone()
160 ));
161 return Ok(Some(next_time));
162 }
163 }
164 }
165
166 // Move to next year, find first matching month
167 for month_num in 1..=12 {
168 if let Ok(month) = MonthOfYear::from_number(month_num) {
169 if target_months.contains(&month) {
170 let next_time = res!(CalClock::new(
171 current_year + 1, month_num, current_time.day(),
172 current_time.hour(), current_time.minute(), current_time.second(), current_time.nanosecond(),
173 current_time.zone().clone()
174 ));
175 return Ok(Some(next_time));
176 }
177 }
178 }
179
180 Ok(None)
181 }
182
183 fn next_business_day_execution(&self, current_time: &CalClock, _zone: &CalClockZone) -> Outcome<Option<CalClock>> {
184 let mut candidate = res!(current_time.add_days(1));
185
186 // Find next business day (up to 10 days ahead to avoid infinite loop)
187 for _ in 0..10 {
188 let day_of_week = candidate.day_of_week();
189 // Simple business day check - Monday through Friday
190 if !matches!(day_of_week, DayOfWeek::Saturday | DayOfWeek::Sunday) {
191 return Ok(Some(candidate));
192 }
193 candidate = res!(candidate.add_days(1));
194 }
195
196 Err(err!("Could not find next business day within 10 days"; Invalid, Range))
197 }
198
199 fn next_monthly_weekday_execution(
200 &self,
201 current_time: &CalClock,
202 week: &WeekOccurrence,
203 target_day: &DayOfWeek,
204 zone: &CalClockZone
205 ) -> Outcome<Option<CalClock>> {
206 let current_year = current_time.year();
207 let current_month = current_time.month();
208
209 // Try current month first
210 if let Some(target_date) = ok!(self.find_monthly_weekday(current_year, current_month, week, target_day, zone)) {
211 if target_date > *current_time {
212 return Ok(Some(target_date));
213 }
214 }
215
216 // Move to next month
217 let (next_year, next_month) = if current_month == 12 {
218 (current_year + 1, 1)
219 } else {
220 (current_year, current_month + 1)
221 };
222
223 if let Some(target_date) = ok!(self.find_monthly_weekday(next_year, next_month, week, target_day, zone)) {
224 Ok(Some(target_date))
225 } else {
226 Err(err!("Could not calculate monthly weekday occurrence"; Invalid, Range))
227 }
228 }
229
230 fn find_monthly_weekday(
231 &self,
232 year: i32,
233 month: u8,
234 week: &WeekOccurrence,
235 target_day: &DayOfWeek,
236 zone: &CalClockZone
237 ) -> Outcome<Option<CalClock>> {
238 let month_enum = res!(MonthOfYear::from_number(month));
239 let days_in_month = month_enum.days_in_month(year);
240
241 let mut candidates = Vec::new();
242
243 // Find all occurrences of target_day in the month
244 for day in 1..=days_in_month {
245 if let Ok(date) = CalClock::new(year, month, day, 0, 0, 0, 0, zone.clone()) {
246 if date.day_of_week() == *target_day {
247 candidates.push(date);
248 }
249 }
250 }
251
252 if candidates.is_empty() {
253 return Ok(None);
254 }
255
256 let selected_date = match week {
257 WeekOccurrence::First => candidates[0].clone(),
258 WeekOccurrence::Second => {
259 if candidates.len() >= 2 {
260 candidates[1].clone()
261 } else {
262 return Ok(None);
263 }
264 },
265 WeekOccurrence::Third => {
266 if candidates.len() >= 3 {
267 candidates[2].clone()
268 } else {
269 return Ok(None);
270 }
271 },
272 WeekOccurrence::Fourth => {
273 if candidates.len() >= 4 {
274 candidates[3].clone()
275 } else {
276 return Ok(None);
277 }
278 },
279 WeekOccurrence::Last => candidates.last().unwrap().clone(),
280 };
281
282 Ok(Some(selected_date))
283 }
284}
285
286#[derive(Debug, Clone, PartialEq)]
287pub struct CronExpression {
288 pub minute: CronField, // 0-59
289 pub hour: CronField, // 0-23
290 pub day: CronField, // 1-31
291 pub month: CronField, // 1-12
292 pub day_of_week: CronField, // 0-6, Sunday is 0
293}
294
295#[derive(Debug, Clone, PartialEq)]
296pub enum CronField {
297 // What each piece of cron syntax parses to.
298 Any, // *
299 Value(u8), // 7
300 List(Vec<u8>), // 1,3,5
301 Range(u8, u8), // 1-5
302 Step(u8), // */5
303}
304
305impl CronExpression {
306 /// A subset of cron: five fields, and no names for months or weekdays.
307 pub fn parse(expr: &str) -> Outcome<Self> {
308 let parts: Vec<&str> = expr.split_whitespace().collect();
309 if parts.len() != 5 {
310 return Err(err!("Cron expression must have 5 fields: minute hour day month day_of_week"; Invalid, Input));
311 }
312
313 Ok(CronExpression {
314 minute: res!(Self::parse_field(parts[0])),
315 hour: res!(Self::parse_field(parts[1])),
316 day: res!(Self::parse_field(parts[2])),
317 month: res!(Self::parse_field(parts[3])),
318 day_of_week: res!(Self::parse_field(parts[4])),
319 })
320 }
321
322 pub fn daily(hour: u8, minute: u8) -> Self {
323 CronExpression {
324 minute: CronField::Value(minute),
325 hour: CronField::Value(hour),
326 day: CronField::Any,
327 month: CronField::Any,
328 day_of_week: CronField::Any,
329 }
330 }
331
332 pub fn weekly(day_of_week: DayOfWeek, hour: u8, minute: u8) -> Self {
333 CronExpression {
334 minute: CronField::Value(minute),
335 hour: CronField::Value(hour),
336 day: CronField::Any,
337 month: CronField::Any,
338 day_of_week: CronField::Value(day_of_week.of() % 7), // Convert to cron format (0=Sunday)
339 }
340 }
341
342 pub fn next_execution(&self, current_time: &CalClock) -> Outcome<Option<CalClock>> {
343 // Simplified implementation - finds next matching minute
344 let mut candidate = res!(current_time.add_minutes(1));
345
346 // Search for next matching time (limit to reasonable range)
347 for _ in 0..(60 * 24 * 32) { // Search up to 32 days
348 if self.matches_time(&candidate) {
349 return Ok(Some(candidate));
350 }
351 candidate = res!(candidate.add_minutes(1));
352 }
353
354 Err(err!("Could not find next cron execution within 32 days"; Invalid, Range))
355 }
356
357 fn matches_time(&self, time: &CalClock) -> bool {
358 self.field_matches(&self.minute, time.minute()) &&
359 self.field_matches(&self.hour, time.hour()) &&
360 self.field_matches(&self.day, time.day()) &&
361 self.field_matches(&self.month, time.month()) &&
362 self.field_matches(&self.day_of_week, time.day_of_week().of() % 7)
363 }
364
365 fn field_matches(&self, field: &CronField, value: u8) -> bool {
366 match field {
367 CronField::Any => true,
368 CronField::Value(v) => *v == value,
369 CronField::List(values) => values.contains(&value),
370 CronField::Range(start, end) => value >= *start && value <= *end,
371 CronField::Step(step) => value % step == 0,
372 }
373 }
374
375 fn parse_field(field: &str) -> Outcome<CronField> {
376 if field == "*" {
377 Ok(CronField::Any)
378 } else if field.contains(',') {
379 let values: Result<Vec<u8>, _> = field.split(',')
380 .map(|s| s.parse::<u8>())
381 .collect();
382 Ok(CronField::List(res!(values.map_err(|e| err!("Invalid cron field value: {}", e; Invalid, Input)))))
383 } else if field.contains('-') {
384 let parts: Vec<&str> = field.split('-').collect();
385 if parts.len() != 2 {
386 return Err(err!("Invalid range format in cron field"; Invalid, Input));
387 }
388 let start = res!(parts[0].parse::<u8>().map_err(|e| err!("Invalid range start: {}", e; Invalid, Input)));
389 let end = res!(parts[1].parse::<u8>().map_err(|e| err!("Invalid range end: {}", e; Invalid, Input)));
390 Ok(CronField::Range(start, end))
391 } else if field.starts_with("*/") {
392 let step_str = &field[2..];
393 let step = res!(step_str.parse::<u8>().map_err(|e| err!("Invalid step value: {}", e; Invalid, Input)));
394 Ok(CronField::Step(step))
395 } else {
396 let value = res!(field.parse::<u8>().map_err(|e| err!("Invalid field value: {}", e; Invalid, Input)));
397 Ok(CronField::Value(value))
398 }
399 }
400}
401
402#[derive(Debug, Clone)]
403pub struct RecurrenceRule {
404 pub pattern: RecurrencePattern,
405 pub exceptions: HashSet<CalClock>, // dates the rule skips
406 pub overrides: HashSet<CalClock>, // dates it runs regardless
407 pub respect_business_calendar: bool,
408 pub zone: CalClockZone,
409}
410
411impl RecurrenceRule {
412 pub fn new(pattern: RecurrencePattern, zone: CalClockZone) -> Self {
413 RecurrenceRule {
414 pattern,
415 exceptions: HashSet::new(),
416 overrides: HashSet::new(),
417 respect_business_calendar: false,
418 zone,
419 }
420 }
421
422 pub fn add_exception(mut self, date: CalClock) -> Self {
423 self.exceptions.insert(date);
424 self
425 }
426
427 pub fn add_override(mut self, date: CalClock) -> Self {
428 self.overrides.insert(date);
429 self
430 }
431
432 pub fn with_business_calendar(mut self) -> Self {
433 self.respect_business_calendar = true;
434 self
435 }
436
437 /// An override wins outright. Otherwise the pattern proposes a time and
438 /// the exceptions and business calendar may push it later.
439 pub fn next_execution(&self, current_time: &CalClock) -> Outcome<Option<CalClock>> {
440 // Check for immediate overrides
441 for override_date in &self.overrides {
442 if override_date > current_time {
443 return Ok(Some(override_date.clone()));
444 }
445 }
446
447 // Get next time from base pattern
448 let mut candidate = res!(self.pattern.next_execution(current_time, &self.zone));
449
450 while let Some(next_time) = candidate {
451 // Check if it's an exception
452 if self.exceptions.contains(&next_time) {
453 candidate = res!(self.pattern.next_execution(&next_time, &self.zone));
454 continue;
455 }
456
457 // Check business calendar if enabled
458 if self.respect_business_calendar {
459 let _calendar = Calendar::new();
460 let calendar_date = next_time.date();
461 if !calendar_date.is_business_day() {
462 candidate = res!(self.pattern.next_execution(&next_time, &self.zone));
463 continue;
464 }
465 }
466
467 return Ok(Some(next_time));
468 }
469
470 Ok(None)
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn test_daily_recurrence() {
480 let zone = CalClockZone::utc();
481 let pattern = RecurrencePattern::Daily;
482 let current = CalClock::new(2024, 1, 1, 12, 0, 0, 0, zone.clone()).unwrap();
483
484 let next = pattern.next_execution(&current, &zone).unwrap().unwrap();
485 assert_eq!(next.day(), 2);
486 assert_eq!(next.hour(), 12);
487 }
488
489 #[test]
490 fn test_weekly_recurrence() {
491 let zone = CalClockZone::utc();
492 let mut days = HashSet::new();
493 days.insert(DayOfWeek::Monday);
494 days.insert(DayOfWeek::Friday);
495
496 let pattern = RecurrencePattern::Weekly(days);
497 let current = CalClock::new(2024, 1, 1, 12, 0, 0, 0, zone.clone()).unwrap(); // Monday
498
499 let next = pattern.next_execution(&current, &zone).unwrap().unwrap();
500 assert_eq!(next.day_of_week(), DayOfWeek::Friday);
501 }
502
503 #[test]
504 fn test_cron_expression_parsing() {
505 let cron = CronExpression::parse("0 9 * * 1").unwrap(); // Every Monday at 9 AM
506
507 assert_eq!(cron.minute, CronField::Value(0));
508 assert_eq!(cron.hour, CronField::Value(9));
509 assert_eq!(cron.day, CronField::Any);
510 assert_eq!(cron.month, CronField::Any);
511 assert_eq!(cron.day_of_week, CronField::Value(1));
512 }
513
514 #[test]
515 fn test_cron_daily() {
516 let cron = CronExpression::daily(14, 30); // 2:30 PM daily
517
518 assert_eq!(cron.minute, CronField::Value(30));
519 assert_eq!(cron.hour, CronField::Value(14));
520 assert_eq!(cron.day, CronField::Any);
521 assert_eq!(cron.month, CronField::Any);
522 assert_eq!(cron.day_of_week, CronField::Any);
523 }
524
525 #[test]
526 fn test_recurrence_rule_exceptions() {
527 let zone = CalClockZone::utc();
528 let pattern = RecurrencePattern::Daily;
529
530 let exception_date = CalClock::new(2024, 1, 2, 12, 0, 0, 0, zone.clone()).unwrap();
531 let rule = RecurrenceRule::new(pattern, zone.clone())
532 .add_exception(exception_date);
533
534 let current = CalClock::new(2024, 1, 1, 12, 0, 0, 0, zone).unwrap();
535 let next = rule.next_execution(&current).unwrap().unwrap();
536
537 // Should skip January 2nd and go to January 3rd
538 assert_eq!(next.day(), 3);
539 }
540}