1use chrono::{offset::Utc, TimeZone};
6use std::any::TypeId;
7use std::time::SystemTime;
8
9use super::{descriptor::CronDescriptor, Schedule};
10use crate::error::ScheduleError;
11
12mod parsing;
13mod time_units;
14use parsing::{parse_longhand, parse_shorthand, CronParsingError, Shorthand};
15use time_units::{Hours, Minutes, MonthDays, Months, TimeUnitField, WeekDays};
16
17pub const MAX_YEAR: Ordinal = 2100;
19
20type Ordinal = u32;
22
23#[derive(Debug)]
54pub struct CronSchedule<Z>
55where
56 Z: TimeZone,
57{
58 minutes: Minutes,
59 hours: Hours,
60 month_days: MonthDays,
61 months: Months,
62 week_days: WeekDays,
63 time_zone: Z,
64}
65
66impl<Z> Schedule for CronSchedule<Z>
67where
68 Z: TimeZone + 'static,
69{
70 fn next_call_at(&self, _last_run_at: Option<SystemTime>) -> Option<SystemTime> {
71 let now = self.time_zone.from_utc_datetime(&Utc::now().naive_utc());
72 self.next(now).map(SystemTime::from)
73 }
74
75 fn describe(&self) -> Option<super::ScheduleDescriptor> {
76 if TypeId::of::<Z>() == TypeId::of::<Utc>() {
77 Some(super::ScheduleDescriptor::cron(self.snapshot()))
78 } else {
79 None
80 }
81 }
82}
83
84impl CronSchedule<Utc> {
85 pub fn new(
92 minutes: Vec<Ordinal>,
93 hours: Vec<Ordinal>,
94 month_days: Vec<Ordinal>,
95 months: Vec<Ordinal>,
96 week_days: Vec<Ordinal>,
97 ) -> Result<Self, ScheduleError> {
98 Self::new_with_time_zone(minutes, hours, month_days, months, week_days, Utc)
99 }
100
101 pub fn from_string(schedule: &str) -> Result<Self, ScheduleError> {
126 Self::from_string_with_time_zone(schedule, Utc)
127 }
128
129 pub fn describe(&self) -> CronDescriptor {
130 self.snapshot()
131 }
132}
133
134impl<Z> CronSchedule<Z>
135where
136 Z: TimeZone + 'static,
137{
138 fn snapshot(&self) -> CronDescriptor {
139 CronDescriptor {
140 minutes: self.minutes.to_vec(),
141 hours: self.hours.to_vec(),
142 month_days: self.month_days.to_vec(),
143 months: self.months.to_vec(),
144 week_days: self.week_days.to_vec(),
145 }
146 }
147}
148
149impl<Z> CronSchedule<Z>
150where
151 Z: TimeZone,
152{
153 pub fn new_with_time_zone(
160 mut minutes: Vec<Ordinal>,
161 mut hours: Vec<Ordinal>,
162 mut month_days: Vec<Ordinal>,
163 mut months: Vec<Ordinal>,
164 mut week_days: Vec<Ordinal>,
165 time_zone: Z,
166 ) -> Result<Self, ScheduleError> {
167 minutes.sort_unstable();
168 minutes.dedup();
169 hours.sort_unstable();
170 hours.dedup();
171 month_days.sort_unstable();
172 month_days.dedup();
173 months.sort_unstable();
174 months.dedup();
175 week_days.sort_unstable();
176 week_days.dedup();
177
178 Self::validate(&minutes, &hours, &month_days, &months, &week_days)?;
179
180 Ok(Self {
181 minutes: Minutes::from_vec(minutes),
182 hours: Hours::from_vec(hours),
183 month_days: MonthDays::from_vec(month_days),
184 months: Months::from_vec(months),
185 week_days: WeekDays::from_vec(week_days),
186 time_zone,
187 })
188 }
189
190 pub fn from_string_with_time_zone(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
215 if schedule.starts_with('@') {
216 Self::from_shorthand(schedule, time_zone)
217 } else {
218 Self::from_longhand(schedule, time_zone)
219 }
220 }
221
222 fn validate(
225 minutes: &[Ordinal],
226 hours: &[Ordinal],
227 month_days: &[Ordinal],
228 months: &[Ordinal],
229 week_days: &[Ordinal],
230 ) -> Result<(), ScheduleError> {
231 use ScheduleError::CronScheduleError;
232
233 if minutes.is_empty() {
234 return Err(CronScheduleError("Minutes were not set".to_string()));
235 }
236 if *minutes.first().unwrap() < Minutes::inclusive_min() {
237 return Err(CronScheduleError(format!(
238 "Minutes cannot be less than {}",
239 Minutes::inclusive_min()
240 )));
241 }
242 if *minutes.last().unwrap() > Minutes::inclusive_max() {
243 return Err(CronScheduleError(format!(
244 "Minutes cannot be more than {}",
245 Minutes::inclusive_max()
246 )));
247 }
248
249 if hours.is_empty() {
250 return Err(CronScheduleError("Hours were not set".to_string()));
251 }
252 if *hours.first().unwrap() < Hours::inclusive_min() {
253 return Err(CronScheduleError(format!(
254 "Hours cannot be less than {}",
255 Hours::inclusive_min()
256 )));
257 }
258 if *hours.last().unwrap() > Hours::inclusive_max() {
259 return Err(CronScheduleError(format!(
260 "Hours cannot be more than {}",
261 Hours::inclusive_max()
262 )));
263 }
264
265 if month_days.is_empty() {
266 return Err(CronScheduleError("Month days were not set".to_string()));
267 }
268 if *month_days.first().unwrap() < MonthDays::inclusive_min() {
269 return Err(CronScheduleError(format!(
270 "Month days cannot be less than {}",
271 MonthDays::inclusive_min()
272 )));
273 }
274 if *month_days.last().unwrap() > MonthDays::inclusive_max() {
275 return Err(CronScheduleError(format!(
276 "Month days cannot be more than {}",
277 MonthDays::inclusive_max()
278 )));
279 }
280
281 if months.is_empty() {
282 return Err(CronScheduleError("Months were not set".to_string()));
283 }
284 if *months.first().unwrap() < Months::inclusive_min() {
285 return Err(CronScheduleError(format!(
286 "Months cannot be less than {}",
287 Months::inclusive_min()
288 )));
289 }
290 if *months.last().unwrap() > Months::inclusive_max() {
291 return Err(CronScheduleError(format!(
292 "Months cannot be more than {}",
293 Months::inclusive_max()
294 )));
295 }
296
297 if week_days.is_empty() {
298 return Err(CronScheduleError("Week days were not set".to_string()));
299 }
300 if *week_days.first().unwrap() < WeekDays::inclusive_min() {
301 return Err(CronScheduleError(format!(
302 "Week days cannot be less than {}",
303 WeekDays::inclusive_min()
304 )));
305 }
306 if *week_days.last().unwrap() > WeekDays::inclusive_max() {
307 return Err(CronScheduleError(format!(
308 "Week days cannot be more than {}",
309 WeekDays::inclusive_max()
310 )));
311 }
312
313 Ok(())
314 }
315
316 fn from_shorthand(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
317 use Shorthand::*;
318 match parse_shorthand(schedule)? {
319 Yearly => Ok(Self {
320 minutes: Minutes::List(vec![0]),
321 hours: Hours::List(vec![0]),
322 month_days: MonthDays::List(vec![1]),
323 week_days: WeekDays::All,
324 months: Months::List(vec![1]),
325 time_zone,
326 }),
327 Monthly => Ok(Self {
328 minutes: Minutes::List(vec![0]),
329 hours: Hours::List(vec![0]),
330 month_days: MonthDays::List(vec![1]),
331 week_days: WeekDays::All,
332 months: Months::All,
333 time_zone,
334 }),
335 Weekly => Ok(Self {
336 minutes: Minutes::List(vec![0]),
337 hours: Hours::List(vec![0]),
338 month_days: MonthDays::All,
339 week_days: WeekDays::List(vec![1]),
340 months: Months::All,
341 time_zone,
342 }),
343 Daily => Ok(Self {
344 minutes: Minutes::List(vec![0]),
345 hours: Hours::List(vec![0]),
346 month_days: MonthDays::All,
347 week_days: WeekDays::All,
348 months: Months::All,
349 time_zone,
350 }),
351 Hourly => Ok(Self {
352 minutes: Minutes::List(vec![0]),
353 hours: Hours::All,
354 month_days: MonthDays::All,
355 week_days: WeekDays::All,
356 months: Months::All,
357 time_zone,
358 }),
359 }
360 }
361
362 fn from_longhand(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
363 let components: Vec<_> = schedule.split_whitespace().collect();
364 if components.len() != 5 {
365 Err(ScheduleError::CronScheduleError(format!(
366 "'{schedule}' is not a valid cron schedule: invalid number of elements"
367 )))
368 } else {
369 let minutes = parse_longhand::<Minutes>(components[0])?;
370 let hours = parse_longhand::<Hours>(components[1])?;
371 let month_days = parse_longhand::<MonthDays>(components[2])?;
372 let months = parse_longhand::<Months>(components[3])?;
373 let week_days = parse_longhand::<WeekDays>(components[4])?;
374
375 CronSchedule::new_with_time_zone(
376 minutes, hours, month_days, months, week_days, time_zone,
377 )
378 }
379 }
380
381 fn next<Tz>(&self, now: chrono::DateTime<Tz>) -> Option<chrono::DateTime<Tz>>
411 where
412 Tz: chrono::TimeZone,
413 {
414 use chrono::{Datelike, Timelike};
415
416 let current_minute = now.minute();
417 let current_hour = now.hour();
418 let current_month_day = now.day();
419 let current_month = now.month();
420 let current_year = now.year() as Ordinal;
421 assert!(current_year <= MAX_YEAR);
422
423 let mut overflow = false;
424 for year in current_year..MAX_YEAR {
425 let month_start = if overflow { 1 } else { current_month };
426 for month in self.months.open_range(month_start) {
427 if month > current_month {
428 overflow = true;
429 }
430 let month_day_start = if overflow { 1 } else { current_month_day };
431 let num_days_in_month = days_in_month(month, year);
432 'day_loop: for month_day in self
433 .month_days
434 .bounded_range(month_day_start, num_days_in_month)
435 {
436 if month_day > current_month_day {
437 overflow = true;
438 }
439 let hour_target = if overflow { 0 } else { current_hour };
440 for hour in self.hours.open_range(hour_target) {
441 if hour > current_hour {
442 overflow = true;
443 }
444 let minute_target = if overflow { 0 } else { current_minute + 1 };
445 for minute in self.minutes.open_range(minute_target) {
446 let timezone = now.timezone();
448 if let chrono::offset::LocalResult::Single(candidate) = timezone
449 .with_ymd_and_hms(year as i32, month, month_day, hour, minute, 0)
450 {
451 if !self
453 .week_days
454 .contains(candidate.weekday().num_days_from_sunday())
455 {
456 continue 'day_loop;
459 }
460
461 return Some(candidate);
462 }
463 }
464 overflow = true;
465 }
466 overflow = true;
467 }
468 overflow = true;
469 }
470 overflow = true;
471 }
472
473 None
474 }
475}
476
477fn is_leap_year(year: Ordinal) -> bool {
478 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
479}
480
481fn days_in_month(month: Ordinal, year: Ordinal) -> Ordinal {
482 let is_leap_year = is_leap_year(year);
483 match month {
484 4 | 6 | 9 | 11 => 30,
485 2 if is_leap_year => 29,
486 2 => 28,
487 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
488 x => panic!(
489 "{} is not a valid value for a month (it must be between 1 and 12)",
490 x
491 ),
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use chrono::{DateTime, NaiveDateTime};
499
500 fn make_utc_date(s: &str) -> DateTime<Utc> {
501 DateTime::<Utc>::from_naive_utc_and_offset(
502 NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z").unwrap(),
503 Utc,
504 )
505 }
506
507 #[test]
508 fn test_cron_next() {
509 let date = make_utc_date("2020-10-19 20:30:00 +0000");
510 let cron_schedule = CronSchedule::from_string("* * * * *").unwrap();
511 let expected_date = make_utc_date("2020-10-19 20:31:00 +0000");
512 assert_eq!(Some(expected_date), cron_schedule.next(date));
513
514 let date = make_utc_date("2020-10-19 20:30:00 +0000");
515 let cron_schedule = CronSchedule::from_string("31 20 * * *").unwrap();
516 let expected_date = make_utc_date("2020-10-19 20:31:00 +0000");
517 assert_eq!(Some(expected_date), cron_schedule.next(date));
518
519 let date = make_utc_date("2020-10-19 20:30:00 +0000");
520 let cron_schedule = CronSchedule::from_string("31 14 4 11 *").unwrap();
521 let expected_date = make_utc_date("2020-11-04 14:31:00 +0000");
522 assert_eq!(Some(expected_date), cron_schedule.next(date));
523
524 let date = make_utc_date("2020-10-19 20:29:23 +0000");
525 let cron_schedule = CronSchedule::from_string("*/5 9-18 1 * 6,0").unwrap();
526 let expected_date = make_utc_date("2020-11-01 09:00:00 +0000");
527 assert_eq!(Some(expected_date), cron_schedule.next(date));
528
529 let date = make_utc_date("2020-10-19 20:29:23 +0000");
530 let cron_schedule = CronSchedule::from_string("3 12 29-31 1-6 2-4").unwrap();
531 let expected_date = make_utc_date("2021-03-30 12:03:00 +0000");
532 assert_eq!(Some(expected_date), cron_schedule.next(date));
533
534 let date = make_utc_date("2020-10-19 20:29:23 +0000");
535 let cron_schedule = CronSchedule::from_string("* * 30 2 *").unwrap();
536 assert_eq!(None, cron_schedule.next(date));
537 }
538
539 #[test]
540 fn test_cron_next_with_date_time() {
541 let date =
542 chrono::DateTime::parse_from_str("2020-10-19 20:29:23 +0112", "%Y-%m-%d %H:%M:%S %z")
543 .unwrap();
544 let time_zone = chrono::offset::FixedOffset::east_opt(3600 + 600 + 120).unwrap();
545 let cron_schedule =
546 CronSchedule::from_string_with_time_zone("3 12 29-31 1-6 2-4", time_zone).unwrap();
547 let expected_date =
548 chrono::DateTime::parse_from_str("2021-03-30 12:03:00 +0112", "%Y-%m-%d %H:%M:%S %z")
549 .unwrap();
550 assert_eq!(Some(expected_date), cron_schedule.next(date));
551 }
552
553 fn cron_schedule_equal<Z: TimeZone>(
554 schedule: &CronSchedule<Z>,
555 minutes: &[Ordinal],
556 hours: &[Ordinal],
557 month_days: &[Ordinal],
558 months: &[Ordinal],
559 week_days: &[Ordinal],
560 ) -> bool {
561 let minutes_equal = match &schedule.minutes {
562 Minutes::All => minutes == (1..=60).collect::<Vec<_>>(),
563 Minutes::List(vec) => minutes == vec,
564 };
565 let hours_equal = match &schedule.hours {
566 Hours::All => hours == (0..=23).collect::<Vec<_>>(),
567 Hours::List(vec) => hours == vec,
568 };
569 let month_days_equal = match &schedule.month_days {
570 MonthDays::All => month_days == (1..=31).collect::<Vec<_>>(),
571 MonthDays::List(vec) => month_days == vec,
572 };
573 let months_equal = match &schedule.months {
574 Months::All => months == (1..=12).collect::<Vec<_>>(),
575 Months::List(vec) => months == vec,
576 };
577 let week_days_equal = match &schedule.week_days {
578 WeekDays::All => week_days == (0..=6).collect::<Vec<_>>(),
579 WeekDays::List(vec) => week_days == vec,
580 };
581
582 minutes_equal && hours_equal && month_days_equal && months_equal && week_days_equal
583 }
584
585 #[test]
586 fn test_from_string() -> Result<(), ScheduleError> {
587 let schedule = CronSchedule::from_string("2 12 8 1 *")?;
588 assert!(cron_schedule_equal(
589 &schedule,
590 &[2],
591 &[12],
592 &[8],
593 &[1],
594 &(0..=6).collect::<Vec<_>>(),
595 ));
596
597 let schedule = CronSchedule::from_string("@yearly")?;
598 assert!(cron_schedule_equal(
599 &schedule,
600 &[0],
601 &[0],
602 &[1],
603 &[1],
604 &(0..=6).collect::<Vec<_>>(),
605 ));
606 let schedule = CronSchedule::from_string("@monthly")?;
607 assert!(cron_schedule_equal(
608 &schedule,
609 &[0],
610 &[0],
611 &[1],
612 &(1..=12).collect::<Vec<_>>(),
613 &(0..=6).collect::<Vec<_>>(),
614 ));
615 let schedule = CronSchedule::from_string("@weekly")?;
616 assert!(cron_schedule_equal(
617 &schedule,
618 &[0],
619 &[0],
620 &(1..=31).collect::<Vec<_>>(),
621 &(1..=12).collect::<Vec<_>>(),
622 &[1],
623 ));
624 let schedule = CronSchedule::from_string("@daily")?;
625 assert!(cron_schedule_equal(
626 &schedule,
627 &[0],
628 &[0],
629 &(1..=31).collect::<Vec<_>>(),
630 &(1..=12).collect::<Vec<_>>(),
631 &(0..=6).collect::<Vec<_>>(),
632 ));
633 let schedule = CronSchedule::from_string("@hourly")?;
634 assert!(cron_schedule_equal(
635 &schedule,
636 &[0],
637 &(0..=23).collect::<Vec<_>>(),
638 &(1..=31).collect::<Vec<_>>(),
639 &(1..=12).collect::<Vec<_>>(),
640 &(0..=6).collect::<Vec<_>>(),
641 ));
642
643 Ok(())
644 }
645}