///|
/// A small, dependency-free cron engine used to schedule forced log
/// rotations.
///
/// `pm2-logrotate` depends on `node-schedule` for this. pm3 only needs the
/// subset of cron that is useful for log rotation, so it is implemented here
/// directly. Supported syntax:
///
/// - `*` and `*/step`
/// - `a`, `a-b`, `a-b/step`, `a/step`
/// - comma separated lists, e.g. `0,30`
/// - month and weekday names, e.g. `JAN`, `MON`
/// - 5 fields (`min hour dom month dow`) or 6 fields with seconds first
/// - the usual `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` aliases
///
/// When both day-of-month and day-of-week are restricted, the schedule fires
/// if *either* matches, matching Vixie cron.
///|
/// Parsed cron schedule. Fields are sorted ascending and de-duplicated.
pub struct CronSpec {
seconds : Array[Int]
minutes : Array[Int]
hours : Array[Int]
days_of_month : Array[Int]
months : Array[Int]
days_of_week : Array[Int]
seconds_restricted : Bool
dom_restricted : Bool
dow_restricted : Bool
} derive(Eq, Debug)
///|
fn cron_name_table(kind : Int) -> Array[String] {
if kind == 0 {
[
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV",
"DEC",
]
} else {
["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
}
}
///|
/// Resolve a numeric or symbolic cron value. `kind` selects the month (0) or
/// weekday (1) name table.
fn cron_atom(text : StringView, kind : Int) -> Int? {
let t = text.trim()
if t.length() == 0 {
return None
}
let upper = t.to_upper().to_owned()
if kind >= 0 {
let names = cron_name_table(kind)
for i in 0.. None
}
}
///|
/// Parse one cron field into the list of accepted values.
/// Returns the values plus whether the field was restricted (i.e. not `*`).
fn parse_cron_field(
field : StringView,
min_value : Int,
max_value : Int,
kind : Int,
) -> (Array[Int], Bool)? {
let raw = field.trim()
if raw.length() == 0 {
return None
}
let restricted = !(raw.length() == 1 && raw.get_char(0) is Some('*'))
let values : Array[Int] = []
for part in raw.split(",") {
let item = part.trim()
if item.length() == 0 {
return None
}
// Separate the optional `/step` suffix.
let (range_text, step) = match item.split_once("/") {
Some((range, step_text)) => {
guard cron_atom(step_text, -1) is Some(s) && s > 0 else { return None }
(range, s)
}
None => (item, 1)
}
let (start, end) = if range_text.length() == 1 &&
range_text.get_char(0) is Some('*') {
(min_value, max_value)
} else {
match range_text.split_once("-") {
Some((a, b)) => {
guard cron_atom(a, kind) is Some(start) else { return None }
guard cron_atom(b, kind) is Some(end) else { return None }
(start, end)
}
None => {
guard cron_atom(range_text, kind) is Some(single) else { return None }
// `a/n` in cron means "from a to max, step n".
if step > 1 {
(single, max_value)
} else {
(single, single)
}
}
}
}
if start < min_value || end > max_value || start > end {
return None
}
let mut v = start
while v <= end {
if !values.contains(v) {
values.push(v)
}
v = v + step
}
}
if values.length() == 0 {
return None
}
values.sort()
Some((values, restricted))
}
///|
/// Expand a `@alias` into its five-field equivalent.
fn expand_cron_alias(expr : String) -> String? {
match expr {
"@yearly" | "@annually" => Some("0 0 1 1 *")
"@monthly" => Some("0 0 1 * *")
"@weekly" => Some("0 0 * * 0")
"@daily" | "@midnight" => Some("0 0 * * *")
"@hourly" => Some("0 * * * *")
_ => None
}
}
///|
/// Parse a cron expression. Returns `None` for malformed input.
pub fn parse_cron(expr : String) -> CronSpec? {
let trimmed = expr.trim()
if trimmed.length() == 0 {
return None
}
let expanded = match expand_cron_alias(trimmed.to_owned()) {
Some(e) => e
None => trimmed.to_owned()
}
let fields : Array[String] = []
for field in expanded.split(" ") {
if field.length() > 0 {
fields.push(field.to_owned())
}
}
let (sec_text, min_text, hour_text, dom_text, month_text, dow_text) = match
fields.length() {
5 => ("0", fields[0], fields[1], fields[2], fields[3], fields[4])
6 => (fields[0], fields[1], fields[2], fields[3], fields[4], fields[5])
_ => return None
}
guard parse_cron_field(sec_text, 0, 59, -1)
is Some((seconds, seconds_restricted)) else {
return None
}
guard parse_cron_field(min_text, 0, 59, -1) is Some((minutes, _)) else {
return None
}
guard parse_cron_field(hour_text, 0, 23, -1) is Some((hours, _)) else {
return None
}
guard parse_cron_field(dom_text, 1, 31, -1)
is Some((days_of_month, dom_restricted)) else {
return None
}
guard parse_cron_field(month_text, 1, 12, 0) is Some((months, _)) else {
return None
}
guard parse_cron_field(dow_text, 0, 6, 1)
is Some((days_of_week, dow_restricted)) else {
return None
}
Some({
seconds,
minutes,
hours,
days_of_month,
months,
days_of_week,
seconds_restricted,
dom_restricted,
dow_restricted,
})
}
///|
/// Does `spec` fire at local Unix second `epoch_secs` (already offset-shifted)?
pub fn cron_matches(spec : CronSpec, local_secs : Int64) -> Bool {
let (_, month, day, hour, minute, second, days) = split_timestamp(local_secs)
if !spec.months.contains(month) {
return false
}
if !spec.hours.contains(hour) || !spec.minutes.contains(minute) {
return false
}
if spec.seconds_restricted && !spec.seconds.contains(second) {
return false
}
let dom_ok = spec.days_of_month.contains(day)
let dow_ok = spec.days_of_week.contains(weekday_from_days(days))
if spec.dom_restricted && spec.dow_restricted {
dom_ok || dow_ok
} else if spec.dom_restricted {
dom_ok
} else if spec.dow_restricted {
dow_ok
} else {
true
}
}
///|
/// Smallest value in `values` that is strictly greater than `after`.
fn first_greater(values : Array[Int], after : Int) -> Int? {
for v in values {
if v > after {
return Some(v)
}
}
None
}
///|
/// Compute the next fire time strictly after `from_secs`.
///
/// `offset_secs` is the local UTC offset applied before evaluating the
/// calendar fields. Returns `None` when no fire time is found within roughly
/// four years (e.g. for an impossible date such as February 30th).
pub fn next_cron_fire(
spec : CronSpec,
from_secs : Int64,
offset_secs : Int,
) -> Int64? {
// Start at the minute that contains `from_secs`, then look for a second
// inside it that is strictly after `from_secs`.
let offset = offset_secs.to_int64()
let mut minute = from_secs / 60L * 60L
// ~4 years of minutes; bounded so impossible schedules terminate.
let max_steps = 366 * 24 * 60 * 4
for _ in 0.. return Some(minute + sec.to_int64())
None => ()
}
} else if minute > from_secs {
return Some(minute)
}
}
minute = minute + 60L
}
None
}