1use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
2use std::fmt;
3
4const MAX_LOOKAHEAD_MINUTES: usize = 5 * 366 * 24 * 60;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct CronSchedule {
8 minutes: CronField,
9 hours: CronField,
10 days_of_month: CronField,
11 months: CronField,
12 days_of_week: CronField,
13}
14
15impl CronSchedule {
16 pub fn parse(expression: &str) -> Result<Self, CronParseError> {
17 let fields = expression.split_whitespace().collect::<Vec<_>>();
18 if fields.len() != 5 {
19 return Err(CronParseError::new("cron expression must have 5 fields"));
20 }
21
22 Ok(Self {
23 minutes: CronField::parse(fields[0], FieldKind::Minute)?,
24 hours: CronField::parse(fields[1], FieldKind::Hour)?,
25 days_of_month: CronField::parse(fields[2], FieldKind::DayOfMonth)?,
26 months: CronField::parse(fields[3], FieldKind::Month)?,
27 days_of_week: CronField::parse(fields[4], FieldKind::DayOfWeek)?,
28 })
29 }
30
31 pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
32 let mut candidate = (after + Duration::minutes(1))
33 .with_second(0)?
34 .with_nanosecond(0)?;
35
36 for _ in 0..MAX_LOOKAHEAD_MINUTES {
38 if self.matches(candidate) {
39 return Some(candidate);
40 }
41 candidate += Duration::minutes(1);
42 }
43
44 None
45 }
46
47 fn matches(&self, at: DateTime<Utc>) -> bool {
48 self.minutes.contains(at.minute())
49 && self.hours.contains(at.hour())
50 && self.months.contains(at.month())
51 && self.matches_day(at)
52 }
53
54 fn matches_day(&self, at: DateTime<Utc>) -> bool {
55 let dom_matches = self.days_of_month.contains(at.day());
56 let dow_matches = self
57 .days_of_week
58 .contains(at.weekday().num_days_from_sunday());
59
60 match (
61 self.days_of_month.is_wildcard(),
62 self.days_of_week.is_wildcard(),
63 ) {
64 (true, true) => true,
65 (true, false) => dow_matches,
66 (false, true) => dom_matches,
67 (false, false) => dom_matches || dow_matches,
68 }
69 }
70}
71
72pub fn validate_cron_expression(expression: &str) -> Result<(), CronParseError> {
73 CronSchedule::parse(expression).map(|_| ())
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct CronParseError {
78 message: String,
79}
80
81impl CronParseError {
82 fn new(message: impl Into<String>) -> Self {
83 Self {
84 message: message.into(),
85 }
86 }
87}
88
89impl fmt::Display for CronParseError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.message)
92 }
93}
94
95impl std::error::Error for CronParseError {}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct CronField {
99 allowed: Vec<bool>,
100 min: u32,
101 max: u32,
102 wildcard: bool,
103}
104
105impl CronField {
106 fn parse(input: &str, kind: FieldKind) -> Result<Self, CronParseError> {
107 let min = kind.min();
108 let max = kind.max();
109 let mut allowed = vec![false; (max + 1) as usize];
110 let wildcard = input == "*";
111
112 if input.trim().is_empty() {
113 return Err(CronParseError::new("cron field must not be empty"));
114 }
115
116 for part in input.split(',') {
117 parse_part(part, kind, &mut allowed)?;
118 }
119
120 Ok(Self {
121 allowed,
122 min,
123 max,
124 wildcard,
125 })
126 }
127
128 fn contains(&self, value: u32) -> bool {
129 if value < self.min || value > self.max {
130 return false;
131 }
132 self.allowed[value as usize]
133 }
134
135 fn is_wildcard(&self) -> bool {
136 self.wildcard
137 }
138}
139
140fn parse_part(part: &str, kind: FieldKind, allowed: &mut [bool]) -> Result<(), CronParseError> {
141 let (base, step) = match part.split_once('/') {
142 Some((base, step)) => {
143 let step = step
144 .parse::<u32>()
145 .map_err(|_| CronParseError::new("cron step must be a positive integer"))?;
146 if step == 0 {
147 return Err(CronParseError::new("cron step must be greater than zero"));
148 }
149 (base, step)
150 }
151 None => (part, 1),
152 };
153
154 let (start, end) = parse_range(base, kind, step != 1)?;
155 let mut value = start;
156 while value <= end {
157 allowed[value as usize] = true;
158 value = value.saturating_add(step);
159 if value == 0 {
160 break;
161 }
162 }
163
164 Ok(())
165}
166
167fn parse_range(base: &str, kind: FieldKind, stepped: bool) -> Result<(u32, u32), CronParseError> {
168 if base == "*" {
169 return Ok((kind.min(), kind.max()));
170 }
171
172 if let Some((start, end)) = base.split_once('-') {
173 let start = parse_value(start, kind)?;
174 let end = parse_value(end, kind)?;
175 if start > end {
176 return Err(CronParseError::new("cron range start must be before end"));
177 }
178 return Ok((start, end));
179 }
180
181 let start = parse_value(base, kind)?;
182 if stepped {
183 Ok((start, kind.max()))
184 } else {
185 Ok((start, start))
186 }
187}
188
189fn parse_value(value: &str, kind: FieldKind) -> Result<u32, CronParseError> {
190 let normalized = value.to_ascii_uppercase();
191 if let Some(named) = kind.named_value(&normalized) {
192 return Ok(named);
193 }
194
195 let parsed = value
196 .parse::<u32>()
197 .map_err(|_| CronParseError::new("cron field contains an invalid value"))?;
198 let parsed = if kind == FieldKind::DayOfWeek && parsed == 7 {
199 0
200 } else {
201 parsed
202 };
203 if parsed < kind.min() || parsed > kind.max() {
204 return Err(CronParseError::new("cron field value is out of range"));
205 }
206 Ok(parsed)
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210enum FieldKind {
211 Minute,
212 Hour,
213 DayOfMonth,
214 Month,
215 DayOfWeek,
216}
217
218impl FieldKind {
219 fn min(self) -> u32 {
220 match self {
221 Self::Minute | Self::Hour | Self::DayOfWeek => 0,
222 Self::DayOfMonth | Self::Month => 1,
223 }
224 }
225
226 fn max(self) -> u32 {
227 match self {
228 Self::Minute => 59,
229 Self::Hour => 23,
230 Self::DayOfMonth => 31,
231 Self::Month => 12,
232 Self::DayOfWeek => 6,
233 }
234 }
235
236 fn named_value(self, value: &str) -> Option<u32> {
237 match self {
238 Self::Month => month_value(value),
239 Self::DayOfWeek => day_of_week_value(value),
240 _ => None,
241 }
242 }
243}
244
245fn month_value(value: &str) -> Option<u32> {
246 [
247 ("JAN", 1),
248 ("FEB", 2),
249 ("MAR", 3),
250 ("APR", 4),
251 ("MAY", 5),
252 ("JUN", 6),
253 ("JUL", 7),
254 ("AUG", 8),
255 ("SEP", 9),
256 ("OCT", 10),
257 ("NOV", 11),
258 ("DEC", 12),
259 ]
260 .into_iter()
261 .find_map(|(name, parsed)| (name == value).then_some(parsed))
262}
263
264fn day_of_week_value(value: &str) -> Option<u32> {
265 [
266 ("SUN", 0),
267 ("MON", 1),
268 ("TUE", 2),
269 ("WED", 3),
270 ("THU", 4),
271 ("FRI", 5),
272 ("SAT", 6),
273 ]
274 .into_iter()
275 .find_map(|(name, parsed)| (name == value).then_some(parsed))
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use chrono::TimeZone;
282
283 #[test]
284 fn cron_next_after_handles_steps_and_names() {
285 let schedule = CronSchedule::parse("*/15 9-17 * JAN,MAR MON-FRI").expect("cron");
286 let after = Utc.with_ymd_and_hms(2026, 1, 5, 9, 7, 10).unwrap();
287
288 assert_eq!(
289 schedule.next_after(after),
290 Some(Utc.with_ymd_and_hms(2026, 1, 5, 9, 15, 0).unwrap())
291 );
292 }
293
294 #[test]
295 fn cron_day_of_month_or_day_of_week_matches_vixie_shape() {
296 let schedule = CronSchedule::parse("0 9 15 * MON").expect("cron");
297
298 assert_eq!(
299 schedule.next_after(Utc.with_ymd_and_hms(2026, 6, 14, 9, 0, 0).unwrap()),
300 Some(Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap())
301 );
302 }
303
304 #[test]
305 fn cron_rejects_invalid_shapes() {
306 assert!(CronSchedule::parse("* * *").is_err());
307 assert!(CronSchedule::parse("*/0 * * * *").is_err());
308 assert!(CronSchedule::parse("60 * * * *").is_err());
309 }
310}