Skip to main content

datetime/
cron.rs

1// cron -- 5-field cron schedules evaluated in a time zone
2//
3//   Cron::parse("30 2 * * 1-5")   minute hour day-of-month month day-of-week; fields: *, n,
4//                                 a-b, a-b/n, */n, lists a,b; month names jan..dec, weekday
5//                                 names sun..sat (0 and 7 = Sunday); @hourly @daily @midnight
6//                                 @weekly @monthly @yearly @annually.  Day of month and day of
7//                                 week restricted both: either matches (Vixie cron).
8//   cron.next_after(ts, &zone)    the first run after ts: a wall time in a DST gap runs at the
9//                                 jump (right after the gap), a repeated wall time runs once
10//                                 (its first occurrence); None when nothing matches within
11//                                 5 years
12//   cron.next_n(ts, &zone, n)     the next n runs
13//   cron.describe(en)             "Täglich um 02:00", "Werktags um 02:00", "Alle 15 Minuten" …
14//   Schedule helpers: daily(h, m), weekly(mask, h, m), monthly(day, h, m), hourly(m) build
15//   the expressions of the usual choices (mask bit 0 = Monday)
16
17use alloc::format;
18use alloc::string::String;
19use alloc::vec::Vec;
20
21use crate::tz::{LocalResult, Zone};
22use crate::{days_in_month, Civil, Timestamp};
23
24/// a parsed cron expression
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Cron {
27    minutes: u64,
28    hours: u32,
29    /// bits 1..31
30    dom: u32,
31    /// bits 1..12
32    months: u16,
33    /// bits 0..6, 0 = Sunday
34    dow: u8,
35    dom_star: bool,
36    dow_star: bool,
37    src: String,
38}
39
40/// a parse error: the field (0 minute .. 4 day of week, 5 = the whole expression) and why
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct CronError {
43    pub field: u8,
44    pub msg: &'static str,
45}
46
47const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
48const DAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
49
50fn value(s: &str, field: u8, min: u32, max: u32) -> Result<u32, CronError> {
51    let l = s.to_ascii_lowercase();
52    if field == 3 {
53        if let Some(i) = MONTHS.iter().position(|m| *m == l) {
54            return Ok(i as u32 + 1);
55        }
56    }
57    if field == 4 {
58        if let Some(i) = DAYS.iter().position(|d| *d == l) {
59            return Ok(i as u32);
60        }
61    }
62    let mut v: u32 = 0;
63    if s.is_empty() || s.len() > 2 {
64        return Err(CronError { field, msg: "number expected" });
65    }
66    for c in s.bytes() {
67        if !c.is_ascii_digit() {
68            return Err(CronError { field, msg: "number expected" });
69        }
70        v = v * 10 + (c - b'0') as u32;
71    }
72    // 7 = Sunday as well
73    let hi = if field == 4 { 7 } else { max };
74    if v < min || v > hi {
75        return Err(CronError { field, msg: "value out of range" });
76    }
77    Ok(if field == 4 && v == 7 { 0 } else { v })
78}
79
80// one field -> (bit set, was "*")
81fn field(s: &str, field: u8, min: u32, max: u32) -> Result<(u64, bool), CronError> {
82    let mut bits = 0u64;
83    let mut star = false;
84    for part in s.split(',') {
85        let (range, step) = match part.split_once('/') {
86            Some((r, st)) => {
87                let n = value(st, 5, 1, 60).map_err(|_| CronError { field, msg: "bad step" })?;
88                (r, n)
89            }
90            None => (part, 1),
91        };
92        let (lo, hi) = if range == "*" {
93            if step == 1 && s == "*" {
94                star = true;
95            }
96            (min, max)
97        } else if let Some((a, hi_s)) = range.split_once('-') {
98            let a = value(a, field, min, max)?;
99            let mut b = value(hi_s, field, min, max)?;
100            if field == 4 && b == 0 && (a > 0 || matches!(hi_s, "7" | "07")) {
101                b = 7; // mon-sun, 0-7
102            }
103            if a > b {
104                return Err(CronError { field, msg: "range runs backwards" });
105            }
106            (a, b)
107        } else {
108            let v = value(range, field, min, max)?;
109            (v, if part.contains('/') { max } else { v })
110        };
111        let mut v = lo;
112        while v <= hi {
113            let b = if field == 4 && v == 7 { 0 } else { v };
114            bits |= 1 << b;
115            v += step;
116        }
117    }
118    if bits == 0 {
119        return Err(CronError { field, msg: "empty field" });
120    }
121    Ok((bits, star))
122}
123
124impl Cron {
125    /// parse an expression (see the module comment)
126    pub fn parse(s: &str) -> Result<Cron, CronError> {
127        let t = s.trim();
128        let expanded = match t {
129            "@hourly" => "0 * * * *",
130            "@daily" | "@midnight" => "0 0 * * *",
131            "@weekly" => "0 0 * * 0",
132            "@monthly" => "0 0 1 * *",
133            "@yearly" | "@annually" => "0 0 1 1 *",
134            _ => t,
135        };
136        let f: Vec<&str> = expanded.split_whitespace().collect();
137        if f.len() != 5 {
138            return Err(CronError { field: 5, msg: "five fields expected" });
139        }
140        let (minutes, _) = field(f[0], 0, 0, 59)?;
141        let (hours, _) = field(f[1], 1, 0, 23)?;
142        let (dom, dom_star) = field(f[2], 2, 1, 31)?;
143        let (months, _) = field(f[3], 3, 1, 12)?;
144        let (dow, dow_star) = field(f[4], 4, 0, 6)?;
145        Ok(Cron {
146            minutes,
147            hours: hours as u32,
148            dom: dom as u32,
149            months: months as u16,
150            dow: dow as u8,
151            dom_star,
152            dow_star,
153            src: String::from(t),
154        })
155    }
156
157    /// the expression as written
158    pub fn as_str(&self) -> &str {
159        &self.src
160    }
161
162    /// whether a date matches the month, day-of-month and day-of-week fields
163    pub fn matches_date(&self, y: i32, m: u8, d: u8) -> bool {
164        if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
165            return false;
166        }
167        if self.months & (1 << m) == 0 {
168            return false;
169        }
170        let dom_ok = self.dom & (1 << d) != 0;
171        let wd = (crate::weekday(y, m, d) + 1) % 7; // 0 = Sunday
172        let dow_ok = self.dow & (1 << wd) != 0;
173        match (self.dom_star, self.dow_star) {
174            (true, true) => true,
175            (true, false) => dow_ok,
176            (false, true) => dom_ok,
177            (false, false) => dom_ok || dow_ok,
178        }
179    }
180
181    /// the first run strictly after `after` in `zone`
182    pub fn next_after(&self, after: Timestamp, zone: &Zone) -> Option<Timestamp> {
183        // start at the next whole minute of the local wall time
184        let mut c = zone.to_local(Timestamp::from_secs(after.secs));
185        c.second = 0;
186        c.nanos = 0;
187        let start = Civil { ..c };
188        let mut day = Civil::date(c.year, c.month, c.day);
189        let mut first_day = true;
190        for _ in 0..(366 * 5) {
191            if self.matches_date(day.year, day.month, day.day) {
192                for h in 0..24u8 {
193                    if self.hours & (1 << h) == 0 || (first_day && h < start.hour) {
194                        continue;
195                    }
196                    for mi in 0..60u8 {
197                        if self.minutes & (1 << mi) == 0 || (first_day && h == start.hour && mi < start.minute) {
198                            continue;
199                        }
200                        let cand = Civil::new(day.year, day.month, day.day, h, mi, 0);
201                        let t = match zone.from_local(&cand) {
202                            LocalResult::Single(t) => t,
203                            LocalResult::Ambiguous(a, _) => a,
204                            LocalResult::Gap { after: j } => j,
205                        };
206                        if t.secs > after.secs {
207                            return Some(t);
208                        }
209                    }
210                }
211            }
212            day = day.add_days(1);
213            first_day = false;
214            // skip whole months that never match
215            while self.months & (1 << day.month) == 0 {
216                let dim = days_in_month(day.year, day.month) as i64;
217                day = day.add_days(dim - day.day as i64 + 1);
218            }
219        }
220        None
221    }
222
223    /// the next n runs after `after`
224    pub fn next_n(&self, after: Timestamp, zone: &Zone, n: usize) -> Vec<Timestamp> {
225        let mut out = Vec::with_capacity(n);
226        let mut t = after;
227        while out.len() < n {
228            match self.next_after(t, zone) {
229                Some(x) => {
230                    out.push(x);
231                    t = x;
232                }
233                None => break,
234            }
235        }
236        out
237    }
238
239    fn single(bits: u64) -> Option<u32> {
240        if bits.count_ones() == 1 { Some(bits.trailing_zeros()) } else { None }
241    }
242
243    /// the schedule in words (German, or English with en)
244    pub fn describe(&self, en: bool) -> String {
245        let all_min = self.minutes == (1u64 << 60) - 1;
246        let all_hours = self.hours == (1u32 << 24) - 1;
247        let all_months = self.months == 0x1FFE;
248        let at = |h: u32, m: u32| format!("{:02}:{:02}", h, m);
249        let m1 = Cron::single(self.minutes);
250        let h1 = Cron::single(self.hours as u64);
251        if all_months && self.dom_star && self.dow_star {
252            if all_min && all_hours {
253                return String::from(if en { "Every minute" } else { "Jede Minute" });
254            }
255            if all_hours {
256                if let Some(m) = m1 {
257                    return if en { format!("Hourly at minute {}", m) } else { format!("Stündlich zur Minute {}", m) };
258                }
259                // */n
260                let n = self.minutes.count_ones();
261                if n > 1 && 60 % n == 0 {
262                    let step = 60 / n;
263                    let mut ok = true;
264                    for k in 0..n {
265                        if self.minutes & (1 << (k * step)) == 0 {
266                            ok = false;
267                        }
268                    }
269                    if ok {
270                        return if en { format!("Every {} minutes", step) } else { format!("Alle {} Minuten", step) };
271                    }
272                }
273            }
274            if let (Some(m), Some(h)) = (m1, h1) {
275                return if en { format!("Daily at {}", at(h, m)) } else { format!("Täglich um {}", at(h, m)) };
276            }
277            if let Some(m) = m1 {
278                let n = self.hours.count_ones();
279                if n > 1 && 24 % n == 0 {
280                    let step = 24 / n;
281                    let first = self.hours.trailing_zeros();
282                    let ok = first < step && (0..n).all(|k| self.hours & (1 << (first + k * step)) != 0);
283                    if ok {
284                        return if en {
285                            format!("Every {} hours at minute {}", step, m)
286                        } else {
287                            format!("Alle {} Stunden zur Minute {}", step, m)
288                        };
289                    }
290                }
291            }
292        }
293        if let (Some(m), Some(h), true) = (m1, h1, all_months) {
294            if self.dom_star && !self.dow_star {
295                if self.dow == 0b0111110 {
296                    return if en { format!("Weekdays at {}", at(h, m)) } else { format!("Werktags um {}", at(h, m)) };
297                }
298                let names_de = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
299                let names_en = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
300                let mut days = Vec::new();
301                for d in [1usize, 2, 3, 4, 5, 6, 0] {
302                    if self.dow & (1 << d) != 0 {
303                        days.push(if en { names_en[d] } else { names_de[d] });
304                    }
305                }
306                let list = days.join(", ");
307                return if en { format!("Weekly on {} at {}", list, at(h, m)) } else { format!("Wöchentlich am {} um {}", list, at(h, m)) };
308            }
309            if !self.dom_star && self.dow_star {
310                if let Some(d) = Cron::single(self.dom as u64) {
311                    return if en {
312                        format!("Monthly on day {} at {}", d, at(h, m))
313                    } else {
314                        format!("Monatlich am {}. um {}", d, at(h, m))
315                    };
316                }
317            }
318        }
319        if en { format!("Custom: {}", self.src) } else { format!("Benutzerdefiniert: {}", self.src) }
320    }
321}
322
323/// "M H * * *"
324pub fn daily(h: u8, m: u8) -> String {
325    format!("{} {} * * *", m.min(59), h.min(23))
326}
327
328/// "M * * * *"
329pub fn hourly(m: u8) -> String {
330    format!("{} * * * *", m.min(59))
331}
332
333/// "M H * * days" with mask bit 0 = Monday .. bit 6 = Sunday (empty mask: every day)
334pub fn weekly(mask: u8, h: u8, m: u8) -> String {
335    if mask & 0x7F == 0 || mask & 0x7F == 0x7F {
336        return daily(h, m);
337    }
338    let mut days = Vec::new();
339    for d in 0..7u8 {
340        if mask & (1 << d) != 0 {
341            days.push(format!("{}", (d + 1) % 7));
342        }
343    }
344    format!("{} {} * * {}", m.min(59), h.min(23), days.join(","))
345}
346
347/// "M H D * *"
348pub fn monthly(day: u8, h: u8, m: u8) -> String {
349    format!("{} {} {} * *", m.min(59), h.min(23), day.clamp(1, 31))
350}
351
352/// the (kind, hour, minute, weekday mask, day of month) of an expression built by the helpers
353/// above: kind 1 hourly, 2 daily, 3 weekly, 4 monthly, 0 anything else (custom)
354pub fn classify(c: &Cron) -> (u8, u8, u8, u8, u8) {
355    let all_hours = c.hours == (1u32 << 24) - 1;
356    let all_months = c.months == 0x1FFE;
357    let m1 = Cron::single(c.minutes);
358    let h1 = Cron::single(c.hours as u64);
359    if !all_months {
360        return (0, 0, 0, 0, 0);
361    }
362    match (m1, h1, c.dom_star, c.dow_star) {
363        (Some(m), _, true, true) if all_hours => (1, 0, m as u8, 0, 0),
364        (Some(m), Some(h), true, true) => (2, h as u8, m as u8, 0x7F, 0),
365        (Some(m), Some(h), true, false) => {
366            let mut mask = 0u8;
367            for d in 0..7u8 {
368                if c.dow & (1 << ((d + 1) % 7)) != 0 {
369                    mask |= 1 << d;
370                }
371            }
372            (3, h as u8, m as u8, mask, 0)
373        }
374        (Some(m), Some(h), false, true) => match Cron::single(c.dom as u64) {
375            Some(d) => (4, h as u8, m as u8, 0, d as u8),
376            None => (0, 0, 0, 0, 0),
377        },
378        _ => (0, 0, 0, 0, 0),
379    }
380}