1use chrono::{Datelike, NaiveDateTime, Timelike};
7
8#[derive(Debug, Clone)]
10pub struct CronExpr {
11 minutes: FieldSet,
12 hours: FieldSet,
13 days_of_month: FieldSet,
14 months: FieldSet,
15 days_of_week: FieldSet,
16 raw: String,
17}
18
19#[derive(Debug, Clone)]
21struct FieldSet {
22 values: Vec<u32>,
23}
24
25impl FieldSet {
26 fn contains(&self, v: u32) -> bool {
27 self.values.contains(&v)
28 }
29
30 fn parse(field: &str, min: u32, max: u32) -> Result<Self, String> {
31 let mut values = Vec::new();
32 for part in field.split(',') {
33 let part = part.trim();
34 if part == "*" {
35 return Ok(Self {
36 values: (min..=max).collect(),
37 });
38 } else if let Some(step) = part.strip_prefix("*/") {
39 let step: u32 = step.parse().map_err(|_| format!("invalid step: {part}"))?;
40 if step == 0 {
41 return Err("step cannot be 0".into());
42 }
43 let mut v = min;
44 while v <= max {
45 values.push(v);
46 v += step;
47 }
48 } else if part.contains('-') {
49 let parts: Vec<&str> = part.splitn(2, '-').collect();
50 let lo: u32 = parts[0]
51 .parse()
52 .map_err(|_| format!("invalid range start: {}", parts[0]))?;
53 let hi: u32 = parts[1]
54 .parse()
55 .map_err(|_| format!("invalid range end: {}", parts[1]))?;
56 if lo > hi || lo < min || hi > max {
57 return Err(format!("range {lo}-{hi} out of bounds ({min}-{max})"));
58 }
59 values.extend(lo..=hi);
60 } else {
61 let v: u32 = part.parse().map_err(|_| format!("invalid value: {part}"))?;
62 if v < min || v > max {
63 return Err(format!("value {v} out of bounds ({min}-{max})"));
64 }
65 values.push(v);
66 }
67 }
68 values.sort_unstable();
69 values.dedup();
70 Ok(Self { values })
71 }
72}
73
74impl CronExpr {
75 pub fn parse(expr: &str) -> Result<Self, String> {
77 let fields: Vec<&str> = expr.split_whitespace().collect();
78 if fields.len() != 5 {
79 return Err(format!(
80 "expected 5 fields (minute hour dom month dow), got {}",
81 fields.len()
82 ));
83 }
84
85 Ok(Self {
86 minutes: FieldSet::parse(fields[0], 0, 59)?,
87 hours: FieldSet::parse(fields[1], 0, 23)?,
88 days_of_month: FieldSet::parse(fields[2], 1, 31)?,
89 months: FieldSet::parse(fields[3], 1, 12)?,
90 days_of_week: FieldSet::parse(fields[4], 0, 6)?,
91 raw: expr.to_string(),
92 })
93 }
94
95 pub fn matches(&self, dt: &NaiveDateTime) -> bool {
97 self.minutes.contains(dt.minute())
98 && self.hours.contains(dt.hour())
99 && self.days_of_month.contains(dt.day())
100 && self.months.contains(dt.month())
101 && self
102 .days_of_week
103 .contains(dt.weekday().num_days_from_sunday())
104 }
105
106 pub fn as_str(&self) -> &str {
108 &self.raw
109 }
110
111 pub fn next_after(&self, after: &NaiveDateTime) -> Option<NaiveDateTime> {
116 let mut candidate = *after + chrono::Duration::minutes(1);
118 candidate = candidate.with_second(0)?.with_nanosecond(0)?;
119
120 let limit = *after + chrono::Duration::days(366);
121 while candidate < limit {
122 if self.matches(&candidate) {
123 return Some(candidate);
124 }
125 candidate += chrono::Duration::minutes(1);
126 }
127 None
128 }
129}
130
131impl std::fmt::Display for CronExpr {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.write_str(&self.raw)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use chrono::NaiveDate;
141
142 fn dt(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
143 NaiveDate::from_ymd_opt(y, m, d)
144 .unwrap()
145 .and_hms_opt(h, min, 0)
146 .unwrap()
147 }
148
149 #[test]
150 fn test_parse_every_minute() {
151 let expr = CronExpr::parse("* * * * *").unwrap();
152 assert!(expr.matches(&dt(2026, 4, 5, 12, 30)));
153 }
154
155 #[test]
156 fn test_parse_specific_time() {
157 let expr = CronExpr::parse("30 9 * * *").unwrap();
158 assert!(expr.matches(&dt(2026, 4, 5, 9, 30)));
159 assert!(!expr.matches(&dt(2026, 4, 5, 9, 31)));
160 assert!(!expr.matches(&dt(2026, 4, 5, 10, 30)));
161 }
162
163 #[test]
164 fn test_parse_step() {
165 let expr = CronExpr::parse("*/15 * * * *").unwrap();
166 assert!(expr.matches(&dt(2026, 1, 1, 0, 0)));
167 assert!(expr.matches(&dt(2026, 1, 1, 0, 15)));
168 assert!(expr.matches(&dt(2026, 1, 1, 0, 30)));
169 assert!(expr.matches(&dt(2026, 1, 1, 0, 45)));
170 assert!(!expr.matches(&dt(2026, 1, 1, 0, 10)));
171 }
172
173 #[test]
174 fn test_parse_range() {
175 let expr = CronExpr::parse("0 9-17 * * *").unwrap();
176 assert!(expr.matches(&dt(2026, 1, 1, 9, 0)));
177 assert!(expr.matches(&dt(2026, 1, 1, 17, 0)));
178 assert!(!expr.matches(&dt(2026, 1, 1, 8, 0)));
179 assert!(!expr.matches(&dt(2026, 1, 1, 18, 0)));
180 }
181
182 #[test]
183 fn test_parse_list() {
184 let expr = CronExpr::parse("0 9,12,18 * * *").unwrap();
185 assert!(expr.matches(&dt(2026, 1, 1, 9, 0)));
186 assert!(expr.matches(&dt(2026, 1, 1, 12, 0)));
187 assert!(expr.matches(&dt(2026, 1, 1, 18, 0)));
188 assert!(!expr.matches(&dt(2026, 1, 1, 10, 0)));
189 }
190
191 #[test]
192 fn test_day_of_week() {
193 let expr = CronExpr::parse("0 9 * * 1").unwrap();
195 assert!(expr.matches(&dt(2026, 4, 6, 9, 0)));
196 assert!(!expr.matches(&dt(2026, 4, 5, 9, 0))); }
198
199 #[test]
200 fn test_next_after() {
201 let expr = CronExpr::parse("30 9 * * *").unwrap();
202 let now = dt(2026, 4, 5, 8, 0);
203 let next = expr.next_after(&now).unwrap();
204 assert_eq!(next, dt(2026, 4, 5, 9, 30));
205 }
206
207 #[test]
208 fn test_next_after_wraps_day() {
209 let expr = CronExpr::parse("0 6 * * *").unwrap();
210 let now = dt(2026, 4, 5, 23, 0);
211 let next = expr.next_after(&now).unwrap();
212 assert_eq!(next, dt(2026, 4, 6, 6, 0));
213 }
214
215 #[test]
216 fn test_invalid_field_count() {
217 assert!(CronExpr::parse("* * *").is_err());
218 }
219
220 #[test]
221 fn test_invalid_value() {
222 assert!(CronExpr::parse("60 * * * *").is_err());
223 }
224
225 #[test]
226 fn test_invalid_step_zero() {
227 assert!(CronExpr::parse("*/0 * * * *").is_err());
228 }
229}