Menu
Public repository / 8d41f1e10aceed387d425b43038f2070e399a349
AkurAI-Build / src/cron.rs
Latest change 8d41f1e10aceed387d425b43038f2070e399a349 - OLI-277: schedules, reusable workflows (include + inputs), per-repository secrets (value or PassVault), native caches by Ólafur Búi Ólafsson
//! Five-field cron expressions (minute hour day-of-month month day-of-week),
//! UTC, for scheduled runs (OLI-277). Supports `*`, `*/n`, `a`, `a-b`, `a-b/n`
//! and comma lists; day-of-week accepts 0-7 (0 and 7 are Sunday) and the
//! three-letter names, month accepts names too.
use anyhow::{Result, bail, ensure};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Cron {
pub expression: String,
minutes: Vec<u8>,
hours: Vec<u8>,
days: Vec<u8>,
months: Vec<u8>,
weekdays: Vec<u8>,
/// `*` on both day fields means "every day"; cron's OR rule applies when both are restricted.
any_day: bool,
any_weekday: bool,
}
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
fn number(token: &str, names: &[&str], offset: u8) -> Result<u8> {
if let Ok(n) = token.parse::<u8>() {
return Ok(n);
}
let lower = token.to_ascii_lowercase();
match names.iter().position(|name| *name == lower) {
Some(index) => Ok(index as u8 + offset),
None => bail!("unknown value {token}"),
}
}
/// Expand one field to the sorted set of values it covers.
fn field(text: &str, min: u8, max: u8, names: &[&str], offset: u8) -> Result<(Vec<u8>, bool)> {
ensure!(!text.is_empty() && text.len() <= 64, "empty cron field");
let mut values = Vec::new();
let mut any = false;
for part in text.split(',') {
let (range, step) = match part.split_once('/') {
Some((range, step)) => {
let step: u8 = step
.parse()
.map_err(|_| anyhow::anyhow!("bad step {step}"))?;
ensure!(step >= 1, "step must be at least 1");
(range, step)
}
None => (part, 1),
};
let (start, end) = if range == "*" {
if step == 1 {
any = true;
}
(min, max)
} else if let Some((a, b)) = range.split_once('-') {
(number(a, names, offset)?, number(b, names, offset)?)
} else {
let value = number(range, names, offset)?;
(value, if step == 1 { value } else { max })
};
ensure!(start <= end, "range {range} is reversed");
ensure!(min <= start && end <= max, "{range} is outside {min}-{max}");
let mut value = start;
while value <= end {
values.push(value);
value = value.saturating_add(step);
if step == 0 {
break;
}
}
}
values.sort_unstable();
values.dedup();
Ok((values, any))
}
impl Cron {
pub fn parse(expression: &str) -> Result<Cron> {
let expression = expression.trim();
let fields: Vec<&str> = expression.split_whitespace().collect();
ensure!(
fields.len() == 5,
"a cron expression has five fields: minute hour day month weekday"
);
let (minutes, _) = field(fields[0], 0, 59, &[], 0)?;
let (hours, _) = field(fields[1], 0, 23, &[], 0)?;
let (days, any_day) = field(fields[2], 1, 31, &[], 0)?;
let (months, _) = field(fields[3], 1, 12, &MONTHS, 1)?;
let (mut weekdays, any_weekday) = field(fields[4], 0, 7, &WEEKDAYS, 0)?;
for day in &mut weekdays {
if *day == 7 {
*day = 0;
}
}
weekdays.sort_unstable();
weekdays.dedup();
Ok(Cron {
expression: expression.to_owned(),
minutes,
hours,
days,
months,
weekdays,
any_day,
any_weekday,
})
}
/// Whether the expression fires in the UTC minute containing `unix`.
pub fn matches(&self, unix: i64) -> bool {
let (minute, hour, day, month, weekday) = civil(unix);
if !self.minutes.contains(&minute)
|| !self.hours.contains(&hour)
|| !self.months.contains(&month)
{
return false;
}
let day_ok = self.days.contains(&day);
let weekday_ok = self.weekdays.contains(&weekday);
match (self.any_day, self.any_weekday) {
(true, true) => true,
(false, true) => day_ok,
(true, false) => weekday_ok,
(false, false) => day_ok || weekday_ok,
}
}
}
/// (minute, hour, day of month, month, weekday with Sunday = 0) in UTC.
fn civil(unix: i64) -> (u8, u8, u8, u8, u8) {
let days = unix.div_euclid(86_400);
let seconds = unix.rem_euclid(86_400);
let weekday = (days + 4).rem_euclid(7) as u8; // 1970-01-01 was a Thursday
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
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;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
(
(seconds / 60 % 60) as u8,
(seconds / 3_600) as u8,
day,
month,
weekday,
)
}
#[cfg(test)]
mod tests {
use super::*;
const SAT_2026_10_03_18_30: i64 = 1_791_052_200;
#[test]
fn civil_fields_are_right() {
// 2026-10-03 18:30 UTC is a Saturday.
assert_eq!(civil(SAT_2026_10_03_18_30), (30, 18, 3, 10, 6));
assert_eq!(civil(0), (0, 0, 1, 1, 4));
assert_eq!(civil(951_782_400), (0, 0, 29, 2, 2)); // 2000-02-29, a Tuesday
}
#[test]
fn parses_steps_ranges_lists_and_names() {
let every_ten = Cron::parse("*/10 * * * *").expect("cron");
assert_eq!(every_ten.minutes, vec![0, 10, 20, 30, 40, 50]);
let nightly = Cron::parse("0 3 * * mon-fri").expect("cron");
assert_eq!(nightly.weekdays, vec![1, 2, 3, 4, 5]);
let odd = Cron::parse("5,35 1-3/2 1,15 jan,JUL 7").expect("cron");
assert_eq!(odd.minutes, vec![5, 35]);
assert_eq!(odd.hours, vec![1, 3]);
assert_eq!(odd.days, vec![1, 15]);
assert_eq!(odd.months, vec![1, 7]);
assert_eq!(odd.weekdays, vec![0]);
for bad in [
"* * * *",
"60 * * * *",
"* 24 * * *",
"* * 0 * *",
"* * * 13 *",
"* * * * 8",
"*/0 * * * *",
"5-1 * * * *",
"a * * * *",
"",
] {
assert!(Cron::parse(bad).is_err(), "{bad:?}");
}
}
#[test]
fn matches_the_minute_and_applies_the_day_or_weekday_rule() {
let at = SAT_2026_10_03_18_30;
assert!(Cron::parse("30 18 * * *").expect("c").matches(at));
assert!(Cron::parse("*/10 * * * *").expect("c").matches(at));
assert!(!Cron::parse("*/10 * * * *").expect("c").matches(at + 60));
assert!(Cron::parse("30 18 * * sat").expect("c").matches(at));
assert!(!Cron::parse("30 18 * * sun").expect("c").matches(at));
assert!(Cron::parse("30 18 3 * *").expect("c").matches(at));
assert!(!Cron::parse("30 18 4 * *").expect("c").matches(at));
// both restricted: either matches (classic cron)
assert!(Cron::parse("30 18 4 * sat").expect("c").matches(at));
assert!(!Cron::parse("30 18 4 * sun").expect("c").matches(at));
assert!(Cron::parse("30 18 * oct *").expect("c").matches(at));
assert!(!Cron::parse("30 18 * nov *").expect("c").matches(at));
}
}