1use std::time::{SystemTime, UNIX_EPOCH};
9
10#[derive(Debug, Clone)]
12pub struct CronExpr {
13 minutes: Vec<u8>, hours: Vec<u8>, days: Vec<u8>, months: Vec<u8>, weekdays: Vec<u8>, }
19
20impl CronExpr {
21 pub fn parse(expr: &str) -> Result<Self, String> {
24 let parts: Vec<&str> = expr.trim().split_whitespace().collect();
25 if parts.len() != 5 {
26 return Err(format!("Expected 5 fields, got {}", parts.len()));
27 }
28
29 Ok(Self {
30 minutes: parse_field(parts[0], 0, 59)?,
31 hours: parse_field(parts[1], 0, 23)?,
32 days: parse_field(parts[2], 1, 31)?,
33 months: parse_field(parts[3], 1, 12)?,
34 weekdays: parse_field(parts[4], 0, 6)?,
35 })
36 }
37
38 pub fn matches(&self, unix_secs: u64) -> bool {
40 let (min, hour, day, month, weekday) = decompose_timestamp(unix_secs);
41 self.minutes.contains(&min)
42 && self.hours.contains(&hour)
43 && self.days.contains(&day)
44 && self.months.contains(&month)
45 && self.weekdays.contains(&weekday)
46 }
47
48 pub fn matches_now(&self) -> bool {
50 let ts = SystemTime::now()
51 .duration_since(UNIX_EPOCH)
52 .unwrap_or_default()
53 .as_secs();
54 self.matches(ts)
55 }
56}
57
58fn parse_field(field: &str, min: u8, max: u8) -> Result<Vec<u8>, String> {
60 if field == "*" {
61 return Ok((min..=max).collect());
62 }
63
64 if let Some(step) = field.strip_prefix("*/") {
66 let step: u8 = step.parse().map_err(|_| format!("Invalid step: {step}"))?;
67 if step == 0 {
68 return Err("Step cannot be 0".into());
69 }
70 return Ok((min..=max).step_by(step as usize).collect());
71 }
72
73 let mut values = Vec::new();
74 for part in field.split(',') {
75 if part.contains('-') {
76 let range: Vec<&str> = part.splitn(2, '-').collect();
78 let start: u8 = range[0]
79 .parse()
80 .map_err(|_| format!("Invalid range start: {}", range[0]))?;
81 let end: u8 = range[1]
82 .parse()
83 .map_err(|_| format!("Invalid range end: {}", range[1]))?;
84 if start > end || start < min || end > max {
85 return Err(format!("Range {start}-{end} out of bounds ({min}-{max})"));
86 }
87 values.extend(start..=end);
88 } else {
89 let val: u8 = part.parse().map_err(|_| format!("Invalid value: {part}"))?;
91 if val < min || val > max {
92 return Err(format!("Value {val} out of bounds ({min}-{max})"));
93 }
94 values.push(val);
95 }
96 }
97
98 values.sort();
99 values.dedup();
100 Ok(values)
101}
102
103fn decompose_timestamp(unix_secs: u64) -> (u8, u8, u8, u8, u8) {
108 let total_secs = unix_secs as i64;
109 let day_secs = total_secs.rem_euclid(86400);
110 let hour = (day_secs / 3600) as u8;
111 let minute = ((day_secs % 3600) / 60) as u8;
112
113 let days = total_secs.div_euclid(86400);
115
116 let weekday = ((days + 4).rem_euclid(7)) as u8;
118
119 let z = days + 719468;
121 let era = if z >= 0 { z } else { z - 146096 } / 146097;
122 let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
124 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
127 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
128
129 (minute, hour, day, month, weekday)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn parse_every_minute() {
138 let cron = CronExpr::parse("* * * * *").unwrap();
139 assert_eq!(cron.minutes.len(), 60);
140 assert_eq!(cron.hours.len(), 24);
141 assert_eq!(cron.days.len(), 31);
142 assert_eq!(cron.months.len(), 12);
143 assert_eq!(cron.weekdays.len(), 7);
144 }
145
146 #[test]
147 fn parse_every_5_minutes() {
148 let cron = CronExpr::parse("*/5 * * * *").unwrap();
149 assert_eq!(
150 cron.minutes,
151 vec![0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
152 );
153 }
154
155 #[test]
156 fn parse_weekdays_at_9am() {
157 let cron = CronExpr::parse("0 9 * * 1-5").unwrap();
158 assert_eq!(cron.minutes, vec![0]);
159 assert_eq!(cron.hours, vec![9]);
160 assert_eq!(cron.weekdays, vec![1, 2, 3, 4, 5]);
161 }
162
163 #[test]
164 fn parse_comma_list() {
165 let cron = CronExpr::parse("0,15,30,45 * * * *").unwrap();
166 assert_eq!(cron.minutes, vec![0, 15, 30, 45]);
167 }
168
169 #[test]
170 fn parse_range() {
171 let cron = CronExpr::parse("* 8-17 * * *").unwrap();
172 assert_eq!(cron.hours, vec![8, 9, 10, 11, 12, 13, 14, 15, 16, 17]);
173 }
174
175 #[test]
176 fn parse_specific_values() {
177 let cron = CronExpr::parse("30 12 1 6 0").unwrap();
178 assert_eq!(cron.minutes, vec![30]);
179 assert_eq!(cron.hours, vec![12]);
180 assert_eq!(cron.days, vec![1]);
181 assert_eq!(cron.months, vec![6]);
182 assert_eq!(cron.weekdays, vec![0]);
183 }
184
185 #[test]
186 fn parse_rejects_wrong_field_count() {
187 assert!(CronExpr::parse("* * *").is_err());
188 assert!(CronExpr::parse("* * * * * *").is_err());
189 assert!(CronExpr::parse("").is_err());
190 }
191
192 #[test]
193 fn parse_rejects_out_of_range() {
194 assert!(CronExpr::parse("60 * * * *").is_err());
195 assert!(CronExpr::parse("* 24 * * *").is_err());
196 assert!(CronExpr::parse("* * 0 * *").is_err());
197 assert!(CronExpr::parse("* * 32 * *").is_err());
198 assert!(CronExpr::parse("* * * 0 *").is_err());
199 assert!(CronExpr::parse("* * * 13 *").is_err());
200 assert!(CronExpr::parse("* * * * 7").is_err());
201 }
202
203 #[test]
204 fn parse_rejects_zero_step() {
205 assert!(CronExpr::parse("*/0 * * * *").is_err());
206 }
207
208 #[test]
209 fn parse_rejects_invalid_tokens() {
210 assert!(CronExpr::parse("abc * * * *").is_err());
211 assert!(CronExpr::parse("* foo * * *").is_err());
212 }
213
214 #[test]
215 fn decompose_epoch() {
216 let (min, hour, day, month, weekday) = decompose_timestamp(0);
218 assert_eq!((min, hour, day, month, weekday), (0, 0, 1, 1, 4));
219 }
220
221 #[test]
222 fn decompose_known_date() {
223 let (min, hour, day, month, weekday) = decompose_timestamp(1705314600);
226 assert_eq!(min, 30);
227 assert_eq!(hour, 10);
228 assert_eq!(day, 15);
229 assert_eq!(month, 1);
230 assert_eq!(weekday, 1); }
232
233 #[test]
234 fn decompose_another_known_date() {
235 let (min, hour, day, month, weekday) = decompose_timestamp(1703462400);
238 assert_eq!(min, 0);
239 assert_eq!(hour, 0);
240 assert_eq!(day, 25);
241 assert_eq!(month, 12);
242 assert_eq!(weekday, 1); }
244
245 #[test]
246 fn matches_every_minute() {
247 let cron = CronExpr::parse("* * * * *").unwrap();
248 assert!(cron.matches(0));
250 assert!(cron.matches(1705314600));
251 }
252
253 #[test]
254 fn matches_specific_time() {
255 let cron = CronExpr::parse("30 10 15 1 1").unwrap();
257 assert!(cron.matches(1705314600));
258 assert!(!cron.matches(1705314600 + 60));
260 }
261
262 #[test]
263 fn matches_weekday_schedule() {
264 let cron = CronExpr::parse("0 9 * * 1-5").unwrap();
266 let monday_9am: u64 = 1705309200; let (min, hour, _, _, weekday) = decompose_timestamp(monday_9am);
269 assert_eq!(min, 0);
270 assert_eq!(hour, 9);
271 assert_eq!(weekday, 1);
272 assert!(cron.matches(monday_9am));
273 }
274
275 #[test]
276 fn matches_now_does_not_panic() {
277 let cron = CronExpr::parse("* * * * *").unwrap();
280 assert!(cron.matches_now());
282 }
283}