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