1use crate::types::{CronError, Result};
21use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeSet;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CronExpression {
28 pub expression: String,
30 minutes: BTreeSet<u32>,
32 hours: BTreeSet<u32>,
34 days: BTreeSet<u32>,
36 months: BTreeSet<u32>,
38 weekdays: BTreeSet<u32>,
40}
41
42impl CronExpression {
43 pub fn parse(expression: &str) -> Result<Self> {
60 let parts: Vec<&str> = expression.split_whitespace().collect();
61
62 if parts.len() != 5 {
63 return Err(CronError::InvalidExpression(format!(
64 "Expected 5 fields, got {}",
65 parts.len()
66 )));
67 }
68
69 let minutes = parse_field(parts[0], 0, 59, "minute")?;
70 let hours = parse_field(parts[1], 0, 23, "hour")?;
71 let days = parse_field(parts[2], 1, 31, "day")?;
72 let months = parse_field(parts[3], 1, 12, "month")?;
73 let weekdays = parse_field(parts[4], 0, 6, "weekday")?;
74
75 Ok(Self {
76 expression: expression.to_string(),
77 minutes,
78 hours,
79 days,
80 months,
81 weekdays,
82 })
83 }
84
85 pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
87 let mut current = after + Duration::minutes(1);
89 current = Utc
90 .with_ymd_and_hms(
91 current.year(),
92 current.month(),
93 current.day(),
94 current.hour(),
95 current.minute(),
96 0,
97 )
98 .single()?;
99
100 let max_iterations = 4 * 366 * 24 * 60;
102
103 for _ in 0..max_iterations {
104 if self.matches(¤t) {
105 return Some(current);
106 }
107 current += Duration::minutes(1);
108 }
109
110 None
111 }
112
113 pub fn matches(&self, dt: &DateTime<Utc>) -> bool {
115 let minute = dt.minute();
116 let hour = dt.hour();
117 let day = dt.day();
118 let month = dt.month();
119 let weekday = dt.weekday().num_days_from_sunday();
120
121 self.minutes.contains(&minute)
122 && self.hours.contains(&hour)
123 && self.days.contains(&day)
124 && self.months.contains(&month)
125 && self.weekdays.contains(&weekday)
126 }
127
128 pub fn describe(&self) -> String {
130 let mut parts = Vec::new();
131
132 if self.minutes.len() == 60 {
134 parts.push("every minute".to_string());
135 } else if self.minutes.len() == 1 {
136 let min = *self.minutes.iter().next().unwrap();
137 if min == 0 {
138 parts.push("at the start of the hour".to_string());
139 } else {
140 parts.push(format!("at minute {}", min));
141 }
142 } else {
143 parts.push(format!("at minutes {:?}", self.minutes));
144 }
145
146 if self.hours.len() < 24 {
148 if self.hours.len() == 1 {
149 let hour = *self.hours.iter().next().unwrap();
150 parts.push(format!("at {}:00", hour));
151 } else {
152 parts.push(format!("during hours {:?}", self.hours));
153 }
154 }
155
156 if self.days.len() < 31 {
158 parts.push(format!("on days {:?}", self.days));
159 }
160
161 if self.months.len() < 12 {
163 parts.push(format!("in months {:?}", self.months));
164 }
165
166 if self.weekdays.len() < 7 {
168 let weekday_names: Vec<&str> = self
169 .weekdays
170 .iter()
171 .map(|&d| match d {
172 0 => "Sun",
173 1 => "Mon",
174 2 => "Tue",
175 3 => "Wed",
176 4 => "Thu",
177 5 => "Fri",
178 6 => "Sat",
179 _ => "?",
180 })
181 .collect();
182 parts.push(format!("on {}", weekday_names.join(", ")));
183 }
184
185 parts.join(", ")
186 }
187}
188
189fn parse_field(field: &str, min: u32, max: u32, name: &str) -> Result<BTreeSet<u32>> {
191 let mut values = BTreeSet::new();
192
193 for part in field.split(',') {
194 let part = part.trim();
195 if part.is_empty() {
196 continue;
197 }
198
199 let (range_part, step) = if let Some(idx) = part.find('/') {
201 let step_str = &part[idx + 1..];
202 let step: u32 = step_str.parse().map_err(|_| {
203 CronError::InvalidExpression(format!(
204 "Invalid step value '{}' in {}",
205 step_str, name
206 ))
207 })?;
208 if step == 0 {
209 return Err(CronError::InvalidExpression(format!(
210 "Step value cannot be 0 in {}",
211 name
212 )));
213 }
214 (&part[..idx], Some(step))
215 } else {
216 (part, None)
217 };
218
219 let (start, end) = if range_part == "*" {
221 (min, max)
222 } else if let Some(idx) = range_part.find('-') {
223 let start: u32 = range_part[..idx].parse().map_err(|_| {
224 CronError::InvalidExpression(format!(
225 "Invalid range start '{}' in {}",
226 &range_part[..idx],
227 name
228 ))
229 })?;
230 let end: u32 = range_part[idx + 1..].parse().map_err(|_| {
231 CronError::InvalidExpression(format!(
232 "Invalid range end '{}' in {}",
233 &range_part[idx + 1..],
234 name
235 ))
236 })?;
237 (start, end)
238 } else {
239 let value: u32 = range_part.parse().map_err(|_| {
240 CronError::InvalidExpression(format!("Invalid value '{}' in {}", range_part, name))
241 })?;
242 (value, value)
243 };
244
245 if start < min || start > max {
247 return Err(CronError::InvalidExpression(format!(
248 "Value {} out of range ({}-{}) in {}",
249 start, min, max, name
250 )));
251 }
252 if end < min || end > max {
253 return Err(CronError::InvalidExpression(format!(
254 "Value {} out of range ({}-{}) in {}",
255 end, min, max, name
256 )));
257 }
258 if start > end {
259 return Err(CronError::InvalidExpression(format!(
260 "Invalid range {}-{} in {}",
261 start, end, name
262 )));
263 }
264
265 let step = step.unwrap_or(1);
267 let mut current = start;
268 while current <= end {
269 values.insert(current);
270 current += step;
271 }
272 }
273
274 if values.is_empty() {
275 return Err(CronError::InvalidExpression(format!(
276 "No valid values in {}",
277 name
278 )));
279 }
280
281 Ok(values)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_parse_every_minute() {
290 let expr = CronExpression::parse("* * * * *").unwrap();
291 assert_eq!(expr.minutes.len(), 60);
292 assert_eq!(expr.hours.len(), 24);
293 assert_eq!(expr.days.len(), 31);
294 assert_eq!(expr.months.len(), 12);
295 assert_eq!(expr.weekdays.len(), 7);
296 }
297
298 #[test]
299 fn test_parse_specific_time() {
300 let expr = CronExpression::parse("30 2 * * *").unwrap();
301 assert_eq!(expr.minutes, BTreeSet::from([30]));
302 assert_eq!(expr.hours, BTreeSet::from([2]));
303 }
304
305 #[test]
306 fn test_parse_step() {
307 let expr = CronExpression::parse("*/5 * * * *").unwrap();
308 assert_eq!(
309 expr.minutes,
310 BTreeSet::from([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55])
311 );
312 }
313
314 #[test]
315 fn test_parse_range() {
316 let expr = CronExpression::parse("0 9-17 * * *").unwrap();
317 assert_eq!(
318 expr.hours,
319 BTreeSet::from([9, 10, 11, 12, 13, 14, 15, 16, 17])
320 );
321 }
322
323 #[test]
324 fn test_parse_list() {
325 let expr = CronExpression::parse("0 0 * * 1,3,5").unwrap();
326 assert_eq!(expr.weekdays, BTreeSet::from([1, 3, 5]));
327 }
328
329 #[test]
330 fn test_parse_range_with_step() {
331 let expr = CronExpression::parse("0-30/10 * * * *").unwrap();
332 assert_eq!(expr.minutes, BTreeSet::from([0, 10, 20, 30]));
333 }
334
335 #[test]
336 fn test_parse_invalid_field_count() {
337 let result = CronExpression::parse("* * *");
338 assert!(result.is_err());
339 }
340
341 #[test]
342 fn test_parse_invalid_value() {
343 let result = CronExpression::parse("60 * * * *");
344 assert!(result.is_err());
345 }
346
347 #[test]
348 fn test_parse_invalid_range() {
349 let result = CronExpression::parse("30-10 * * * *");
350 assert!(result.is_err());
351 }
352
353 #[test]
354 fn test_parse_zero_step() {
355 let result = CronExpression::parse("*/0 * * * *");
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn test_next_after() {
361 let expr = CronExpression::parse("0 * * * *").unwrap();
362 let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 30, 0).unwrap();
363 let next = expr.next_after(now).unwrap();
364 assert_eq!(next.hour(), 11);
365 assert_eq!(next.minute(), 0);
366 }
367
368 #[test]
369 fn test_next_after_specific_time() {
370 let expr = CronExpression::parse("30 14 * * *").unwrap();
371 let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 0, 0).unwrap();
372 let next = expr.next_after(now).unwrap();
373 assert_eq!(next.day(), 5);
374 assert_eq!(next.hour(), 14);
375 assert_eq!(next.minute(), 30);
376 }
377
378 #[test]
379 fn test_next_after_next_day() {
380 let expr = CronExpression::parse("0 2 * * *").unwrap();
381 let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 0, 0).unwrap();
382 let next = expr.next_after(now).unwrap();
383 assert_eq!(next.day(), 6);
384 assert_eq!(next.hour(), 2);
385 assert_eq!(next.minute(), 0);
386 }
387
388 #[test]
389 fn test_matches() {
390 let expr = CronExpression::parse("30 14 * * 1").unwrap();
391 let dt = Utc.with_ymd_and_hms(2026, 2, 2, 14, 30, 0).unwrap();
393 assert!(expr.matches(&dt));
394
395 let dt = Utc.with_ymd_and_hms(2026, 2, 3, 14, 30, 0).unwrap();
397 assert!(!expr.matches(&dt));
398 }
399
400 #[test]
401 fn test_describe() {
402 let expr = CronExpression::parse("0 9 * * 1-5").unwrap();
403 let desc = expr.describe();
404 assert!(desc.contains("Mon"));
405 assert!(desc.contains("Fri"));
406 }
407}