AkurAI Build
Menu

Public repository / main

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));
    }
}