scheduler/model/
cron_schedule.rs1use crate::error::SchedulerError;
2use chrono::{DateTime, Utc};
3use chrono_tz::Tz;
4use cron::Schedule as ParsedCronSchedule;
5use std::fmt::{self, Debug, Display, Formatter};
6use std::str::FromStr;
7
8#[derive(Clone)]
9pub struct CronSchedule {
10 source: String,
11 parsed: ParsedCronSchedule,
12}
13
14impl CronSchedule {
15 pub fn parse(expression: &str) -> Result<Self, SchedulerError> {
16 validate_field_count(expression)?;
17
18 let parsed_expression = format!("0 {expression}");
19 let parsed = ParsedCronSchedule::from_str(&parsed_expression).map_err(|error| {
20 SchedulerError::invalid_cron(format!("invalid cron expression `{expression}`: {error}"))
21 })?;
22
23 Ok(Self {
24 source: expression.to_string(),
25 parsed,
26 })
27 }
28
29 pub fn as_str(&self) -> &str {
30 &self.source
31 }
32
33 pub(crate) fn next_after(&self, after: DateTime<Utc>, timezone: Tz) -> Option<DateTime<Utc>> {
34 let after = after.with_timezone(&timezone);
35 self.parsed
36 .after(&after)
37 .next()
38 .map(|value| value.with_timezone(&Utc))
39 }
40}
41
42impl Debug for CronSchedule {
43 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
44 f.debug_tuple("CronSchedule").field(&self.source).finish()
45 }
46}
47
48impl Display for CronSchedule {
49 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50 f.write_str(&self.source)
51 }
52}
53
54impl PartialEq for CronSchedule {
55 fn eq(&self, other: &Self) -> bool {
56 self.source == other.source
57 }
58}
59
60impl Eq for CronSchedule {}
61
62impl FromStr for CronSchedule {
63 type Err = SchedulerError;
64
65 fn from_str(expression: &str) -> Result<Self, Self::Err> {
66 Self::parse(expression)
67 }
68}
69
70impl TryFrom<&str> for CronSchedule {
71 type Error = SchedulerError;
72
73 fn try_from(expression: &str) -> Result<Self, Self::Error> {
74 Self::parse(expression)
75 }
76}
77
78fn validate_field_count(expression: &str) -> Result<(), SchedulerError> {
79 let field_count = expression.split_whitespace().count();
80 if field_count == 5 {
81 return Ok(());
82 }
83
84 Err(SchedulerError::invalid_cron(format!(
85 "cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got {field_count}: `{expression}`"
86 )))
87}
88
89#[cfg(test)]
90mod tests {
91 use super::CronSchedule;
92 use chrono::{TimeZone, Timelike, Utc};
93 use chrono_tz::{Asia::Shanghai, UTC};
94
95 #[test]
96 fn parses_standard_five_field_expression() {
97 let schedule = CronSchedule::parse("*/15 9-17 * * Mon-Fri").unwrap();
98
99 assert_eq!(schedule.as_str(), "*/15 9-17 * * Mon-Fri");
100 }
101
102 #[test]
103 fn rejects_non_five_field_expressions() {
104 let error = CronSchedule::parse("0 */5 * * * *").unwrap_err();
105 assert!(
106 error
107 .to_string()
108 .contains("cron expression must have exactly 5 fields")
109 );
110
111 let error = CronSchedule::parse("@hourly").unwrap_err();
112 assert!(
113 error
114 .to_string()
115 .contains("cron expression must have exactly 5 fields")
116 );
117 }
118
119 #[test]
120 fn rejects_invalid_five_field_expression() {
121 let error = CronSchedule::parse("bogus * * * *").unwrap_err();
122 let message = error.to_string();
123
124 assert!(message.contains("invalid cron expression `bogus * * * *`"));
125 }
126
127 #[test]
128 fn next_after_uses_configured_timezone() {
129 let schedule = CronSchedule::parse("0 9 * * *").unwrap();
130 let start = Utc.with_ymd_and_hms(2026, 4, 3, 0, 30, 0).unwrap();
131
132 let shanghai_next = schedule.next_after(start, Shanghai).unwrap();
133 let utc_next = schedule.next_after(start, UTC).unwrap();
134
135 assert_eq!(
136 shanghai_next,
137 Utc.with_ymd_and_hms(2026, 4, 3, 1, 0, 0).unwrap()
138 );
139 assert_eq!(utc_next, Utc.with_ymd_and_hms(2026, 4, 3, 9, 0, 0).unwrap());
140 }
141
142 #[test]
143 fn next_after_advances_from_the_scheduled_time() {
144 let schedule = CronSchedule::parse("* * * * *").unwrap();
145 let scheduled_at = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 0).unwrap();
146
147 let next = schedule.next_after(scheduled_at, UTC).unwrap();
148
149 assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 3, 0).unwrap());
150 assert_eq!(next.second(), 0);
151 }
152}