1use crate::error::{Error, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Schedule {
19 minutes: u64,
20 hours: u32,
21 doms: u32,
23 months: u16,
25 dows: u8,
27 dom_star: bool,
28 dow_star: bool,
29 offset: i32,
31 text: String,
32}
33
34const MONTHS: [&str; 12] = [
35 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
36];
37const DAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
38
39fn field(s: &str, lo: u32, hi: u32, names: &[&str], what: &str) -> Result<(u64, bool)> {
42 let bad = |why: &str| Error::invalid(format!("cron {what} {s:?}: {why}"));
43 let num = |t: &str| -> Result<u32> {
44 if let Ok(n) = t.parse::<u32>() {
45 return Ok(n);
46 }
47 let l = t.to_ascii_lowercase();
48 names
49 .iter()
50 .position(|n| *n == l)
51 .map(|i| i as u32 + if names.len() == 12 { 1 } else { 0 })
52 .ok_or_else(|| bad(&format!("{t:?} is not a number or a name")))
53 };
54 let mut bits = 0u64;
55 let mut star = false;
56 for part in s.split(',') {
57 if part.is_empty() {
58 return Err(bad("empty list item"));
59 }
60 let (range, step) = match part.split_once('/') {
61 Some((r, st)) => {
62 let n: u32 = st
63 .parse()
64 .map_err(|_| bad(&format!("step {st:?} is not a number")))?;
65 if n == 0 {
66 return Err(bad("a step of 0"));
67 }
68 (r, Some(n))
69 }
70 None => (part, None),
71 };
72 let (a, b) = if range == "*" {
73 if step.unwrap_or(1) == 1 {
74 star = true;
75 }
76 (lo, hi)
77 } else if let Some((a, b)) = range.split_once('-') {
78 (num(a)?, num(b)?)
79 } else {
80 let a = num(range)?;
81 (a, if step.is_some() { hi } else { a })
83 };
84 if a < lo || b > hi || a > b {
85 return Err(bad(&format!("{a}-{b} is outside {lo}-{hi}")));
86 }
87 let step = step.unwrap_or(1);
88 let mut v = a;
89 while v <= b {
90 bits |= 1 << v;
91 v += step;
92 }
93 }
94 Ok((bits, star))
95}
96
97pub fn parse_offset(s: &str) -> Result<i32> {
99 let t = s.trim();
100 if t.is_empty() || t.eq_ignore_ascii_case("utc") || t == "Z" {
101 return Ok(0);
102 }
103 let bad = || {
104 Error::invalid(format!(
105 "timezone {s:?}: UTC or a fixed offset such as +02:00 (named zones are not supported)"
106 ))
107 };
108 let t = t
109 .strip_prefix("UTC")
110 .or_else(|| t.strip_prefix("utc"))
111 .unwrap_or(t);
112 let (sign, rest) = match t.as_bytes().first() {
113 Some(b'+') => (1, &t[1..]),
114 Some(b'-') => (-1, &t[1..]),
115 _ => return Err(bad()),
116 };
117 let (h, m) = match rest.split_once(':') {
118 Some((h, m)) => (h, m),
119 None if rest.len() == 4 => rest.split_at(2),
120 None => (rest, "0"),
121 };
122 let h: i32 = h.parse().map_err(|_| bad())?;
123 let m: i32 = m.parse().map_err(|_| bad())?;
124 if h > 14 || m > 59 {
125 return Err(bad());
126 }
127 Ok(sign * (h * 3600 + m * 60))
128}
129
130impl Schedule {
131 pub fn parse(expr: &str) -> Result<Schedule> {
133 Schedule::parse_in(expr, 0)
134 }
135
136 pub fn parse_in(expr: &str, offset: i32) -> Result<Schedule> {
138 let text = expr.trim();
139 let expanded = match text.to_ascii_lowercase().as_str() {
140 "@yearly" | "@annually" => "0 0 1 1 *",
141 "@monthly" => "0 0 1 * *",
142 "@weekly" => "0 0 * * 0",
143 "@daily" | "@midnight" => "0 0 * * *",
144 "@hourly" => "0 * * * *",
145 a if a.starts_with('@') => {
146 return Err(Error::invalid(format!(
147 "cron {text:?}: aliases are @yearly, @monthly, @weekly, @daily and @hourly"
148 )));
149 }
150 _ => text,
151 };
152 let f: Vec<&str> = expanded.split_whitespace().collect();
153 if f.len() != 5 {
154 return Err(Error::invalid(format!(
155 "cron {text:?}: five fields (minute hour day-of-month month day-of-week) or an alias such as @daily"
156 )));
157 }
158 let (minutes, _) = field(f[0], 0, 59, &[], "minute")?;
159 let (hours, _) = field(f[1], 0, 23, &[], "hour")?;
160 let (doms, dom_star) = field(f[2], 1, 31, &[], "day of month")?;
161 let (months, _) = field(f[3], 1, 12, &MONTHS, "month")?;
162 let (mut dows, dow_star) = field(f[4], 0, 7, &DAYS, "day of week")?;
163 if dows & (1 << 7) != 0 {
164 dows = (dows | 1) & 0x7f;
165 }
166 let s = Schedule {
167 minutes,
168 hours: hours as u32,
169 doms: doms as u32,
170 months: months as u16,
171 dows: dows as u8,
172 dom_star,
173 dow_star,
174 offset,
175 text: text.to_string(),
176 };
177 if s.next_after(946_684_800).is_none() {
179 return Err(Error::invalid(format!("cron {text:?}: never fires")));
180 }
181 Ok(s)
182 }
183
184 pub fn as_str(&self) -> &str {
186 &self.text
187 }
188
189 fn day_matches(&self, y: i64, m: u32, d: u32) -> bool {
190 if self.months & (1 << m) == 0 {
191 return false;
192 }
193 let dom = self.doms & (1 << d) != 0;
194 let dow = self.dows & (1 << weekday(y, m, d)) != 0;
195 match (self.dom_star, self.dow_star) {
196 (true, true) => true,
197 (false, true) => dom,
198 (true, false) => dow,
199 (false, false) => dom || dow,
200 }
201 }
202
203 pub fn next_after(&self, t: i64) -> Option<i64> {
206 let local = t + self.offset as i64;
207 let start = local.div_euclid(60) * 60 + 60;
209 let first_day = start.div_euclid(86_400);
210 for day in first_day..first_day + 366 * 8 {
212 let (y, m, d) = civil_from_days(day);
213 if !self.day_matches(y, m, d) {
214 continue;
215 }
216 let from = if day == first_day {
217 start.rem_euclid(86_400) / 60
218 } else {
219 0
220 };
221 for mm in from..1440 {
222 let (h, mi) = (mm / 60, mm % 60);
223 if self.hours & (1 << h) != 0 && self.minutes & (1 << mi) != 0 {
224 return Some(day * 86_400 + mm * 60 - self.offset as i64);
225 }
226 }
227 }
228 None
229 }
230}
231
232impl std::fmt::Display for Schedule {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.write_str(&self.text)
235 }
236}
237
238pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
241 let z = z + 719_468;
242 let era = z.div_euclid(146_097);
243 let doe = z.rem_euclid(146_097);
244 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
245 let y = yoe + era * 400;
246 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
247 let mp = (5 * doy + 2) / 153;
248 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
249 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
250 (if m <= 2 { y + 1 } else { y }, m, d)
251}
252
253pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
255 let y = if m <= 2 { y - 1 } else { y };
256 let era = y.div_euclid(400);
257 let yoe = y.rem_euclid(400);
258 let m = m as i64;
259 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
260 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
261 era * 146_097 + doe - 719_468
262}
263
264fn weekday(y: i64, m: u32, d: u32) -> u32 {
266 (days_from_civil(y, m, d) + 4).rem_euclid(7) as u32
268}
269
270pub fn compact_utc(t: i64) -> String {
272 let (y, m, d) = civil_from_days(t.div_euclid(86_400));
273 let s = t.rem_euclid(86_400);
274 format!(
275 "{y:04}{m:02}{d:02}T{:02}{:02}{:02}Z",
276 s / 3600,
277 s % 3600 / 60,
278 s % 60
279 )
280}
281
282pub fn rfc3339(t: i64) -> String {
284 let (y, m, d) = civil_from_days(t.div_euclid(86_400));
285 let s = t.rem_euclid(86_400);
286 format!(
287 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
288 s / 3600,
289 s % 3600 / 60,
290 s % 60
291 )
292}
293
294pub fn parse_compact_utc(s: &str) -> Option<i64> {
296 let b = s.as_bytes();
297 if b.len() != 16 || b[8] != b'T' || b[15] != b'Z' {
298 return None;
299 }
300 let n = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
301 let (y, mo, d) = (n(0..4)?, n(4..6)?, n(6..8)?);
302 let (h, mi, se) = (n(9..11)?, n(11..13)?, n(13..15)?);
303 if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
304 return None;
305 }
306 Some(days_from_civil(y, mo as u32, d as u32) * 86_400 + h * 3600 + mi * 60 + se)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn at(y: i64, mo: u32, d: u32, h: i64, mi: i64) -> i64 {
315 days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60
316 }
317
318 fn next(expr: &str, t: i64) -> i64 {
319 Schedule::parse(expr).unwrap().next_after(t).unwrap()
320 }
321
322 #[test]
323 fn civil_round_trip() {
324 assert_eq!(civil_from_days(0), (1970, 1, 1));
325 assert_eq!(days_from_civil(2000, 3, 1), 11_017);
326 for z in [-1000, 0, 10_957, 11_016, 11_017, 19_782, 20_000, 60_000] {
327 let (y, m, d) = civil_from_days(z);
328 assert_eq!(days_from_civil(y, m, d), z);
329 }
330 assert_eq!(weekday(1970, 1, 1), 4);
331 assert_eq!(weekday(2026, 10, 3), 6, "a Saturday");
332 assert_eq!(compact_utc(at(2026, 10, 3, 4, 5) + 6), "20261003T040506Z");
333 assert_eq!(
334 parse_compact_utc("20261003T040506Z"),
335 Some(at(2026, 10, 3, 4, 5) + 6)
336 );
337 assert_eq!(parse_compact_utc("20261003T0405Z"), None);
338 assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
339 }
340
341 #[test]
342 fn every_minute_and_steps() {
343 let t = at(2026, 10, 3, 12, 0);
344 assert_eq!(next("* * * * *", t), t + 60);
345 assert_eq!(next("* * * * *", t + 30), t + 60);
347 assert_eq!(next("*/15 * * * *", t), at(2026, 10, 3, 12, 15));
348 assert_eq!(
349 next("*/15 * * * *", at(2026, 10, 3, 12, 50)),
350 at(2026, 10, 3, 13, 0)
351 );
352 assert_eq!(next("5/20 * * * *", t), at(2026, 10, 3, 12, 5));
353 assert_eq!(
354 next("5/20 * * * *", at(2026, 10, 3, 12, 45)),
355 at(2026, 10, 3, 13, 5)
356 );
357 assert_eq!(
358 next("10-20/5 * * * *", at(2026, 10, 3, 12, 16)),
359 at(2026, 10, 3, 12, 20)
360 );
361 assert_eq!(next("0,30 */6 * * *", t), at(2026, 10, 3, 12, 30));
362 assert_eq!(
363 next("0,30 */6 * * *", at(2026, 10, 3, 12, 30)),
364 at(2026, 10, 3, 18, 0)
365 );
366 assert_eq!(
367 next("59 23 * * *", at(2026, 12, 31, 23, 59)),
368 at(2027, 1, 1, 23, 59)
369 );
370 }
371
372 #[test]
373 fn aliases() {
374 let t = at(2026, 10, 3, 12, 34);
375 assert_eq!(next("@hourly", t), at(2026, 10, 3, 13, 0));
376 assert_eq!(next("@daily", t), at(2026, 10, 4, 0, 0));
377 assert_eq!(next("@midnight", t), at(2026, 10, 4, 0, 0));
378 assert_eq!(
379 next("@weekly", t),
380 at(2026, 10, 4, 0, 0),
381 "the 4th is a Sunday"
382 );
383 assert_eq!(next("@monthly", t), at(2026, 11, 1, 0, 0));
384 assert_eq!(next("@yearly", t), at(2027, 1, 1, 0, 0));
385 assert_eq!(next("@annually", t), at(2027, 1, 1, 0, 0));
386 assert!(Schedule::parse("@reboot").is_err());
387 }
388
389 #[test]
390 fn month_ends_and_leap_years() {
391 assert_eq!(
393 next("0 0 31 * *", at(2026, 4, 1, 0, 0)),
394 at(2026, 5, 31, 0, 0)
395 );
396 assert_eq!(
397 next("0 0 31 * *", at(2026, 5, 31, 0, 0)),
398 at(2026, 7, 31, 0, 0)
399 );
400 assert_eq!(
402 next("0 12 29 2 *", at(2026, 3, 1, 0, 0)),
403 at(2028, 2, 29, 12, 0)
404 );
405 assert_eq!(
407 next("0 0 29 2 *", at(2096, 3, 1, 0, 0)),
408 at(2104, 2, 29, 0, 0)
409 );
410 assert_eq!(
411 next("0 0 29 2 *", at(1999, 1, 1, 0, 0)),
412 at(2000, 2, 29, 0, 0)
413 );
414 assert_eq!(
416 next("0 0 28,29 2 *", at(2026, 2, 28, 0, 0)),
417 at(2027, 2, 28, 0, 0)
418 );
419 assert_eq!(
421 next("0 0 1 1 *", at(2026, 12, 31, 23, 59)),
422 at(2027, 1, 1, 0, 0)
423 );
424 assert!(Schedule::parse("0 0 30 2 *").is_err());
426 assert!(Schedule::parse("0 0 31 4,6,9,11 *").is_err());
427 }
428
429 #[test]
430 fn days_of_week_and_names() {
431 let sat = at(2026, 10, 3, 9, 0);
432 assert_eq!(next("0 9 * * mon-fri", sat), at(2026, 10, 5, 9, 0));
433 assert_eq!(next("0 9 * * 1-5", sat), at(2026, 10, 5, 9, 0));
434 assert_eq!(next("0 9 * * 7", sat), at(2026, 10, 4, 9, 0));
436 assert_eq!(next("0 9 * * SUN", sat), at(2026, 10, 4, 9, 0));
437 assert_eq!(next("0 0 1 JAN-mar *", sat), at(2027, 1, 1, 0, 0));
438 assert_eq!(next("0 0 13 * 5", sat), at(2026, 10, 9, 0, 0));
440 assert_eq!(
441 next("0 0 13 * 5", at(2026, 10, 9, 0, 0)),
442 at(2026, 10, 13, 0, 0)
443 );
444 assert_eq!(
446 next("0 0 * * 5", at(2026, 10, 9, 0, 0)),
447 at(2026, 10, 16, 0, 0)
448 );
449 }
450
451 #[test]
452 fn errors() {
453 for bad in [
454 "",
455 "* * * *",
456 "* * * * * *",
457 "60 * * * *",
458 "* 24 * * *",
459 "* * 0 * *",
460 "* * * 13 *",
461 "* * * * 8",
462 "*/0 * * * *",
463 "5-1 * * * *",
464 "a * * * *",
465 "1,,2 * * * *",
466 "* * * foo *",
467 ] {
468 assert!(Schedule::parse(bad).is_err(), "{bad:?}");
469 }
470 let e = Schedule::parse("61 * * * *").unwrap_err().to_string();
471 assert!(e.contains("minute"), "{e}");
472 }
473
474 #[test]
475 fn offsets() {
476 assert_eq!(parse_offset("UTC").unwrap(), 0);
477 assert_eq!(parse_offset("").unwrap(), 0);
478 assert_eq!(parse_offset("+02:00").unwrap(), 7200);
479 assert_eq!(parse_offset("-0830").unwrap(), -30_600);
480 assert_eq!(parse_offset("UTC+5").unwrap(), 18_000);
481 assert!(parse_offset("Europe/Berlin").is_err());
482 assert!(parse_offset("+25:00").is_err());
483 let s = Schedule::parse_in("0 9 * * *", 7200).unwrap();
485 assert_eq!(
486 s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
487 at(2026, 10, 3, 7, 0)
488 );
489 let s = Schedule::parse_in("0 1 * * *", 7200).unwrap();
491 assert_eq!(
492 s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
493 at(2026, 10, 3, 23, 0)
494 );
495 let s = Schedule::parse_in("30 0 * * 0", -3600).unwrap();
497 assert_eq!(
498 s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
499 at(2026, 10, 4, 1, 30)
500 );
501 }
502}