loopd: five-field UTC cron matcher
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
//! Five-field UTC crontab matcher (M5d spec, section 4.2). No cron crate.
|
||||
|
||||
use bxcore::Timestamp;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CronError {
|
||||
Fields(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CronError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CronError::Fields(schedule) => {
|
||||
write!(f, "cron schedule {schedule:?} is not five UTC fields")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CronError {}
|
||||
|
||||
/// Whether `schedule` matches `now` in UTC.
|
||||
pub fn due(schedule: &str, now: Timestamp) -> Result<bool, CronError> {
|
||||
let fields: Vec<&str> = schedule.split_whitespace().collect();
|
||||
if fields.len() != 5 {
|
||||
return Err(CronError::Fields(schedule.to_string()));
|
||||
}
|
||||
let rfc = now.to_rfc3339();
|
||||
let minute = number(&rfc, 14, 16, schedule)?;
|
||||
let hour = number(&rfc, 11, 13, schedule)?;
|
||||
let day = number(&rfc, 8, 10, schedule)?;
|
||||
let month = number(&rfc, 5, 7, schedule)?;
|
||||
let dow = {
|
||||
let days = now.unix_millis() / 1000 / 86_400;
|
||||
((days + 4) % 7) as u32
|
||||
};
|
||||
Ok(match_field(fields[0], minute, 0, 59, schedule)?
|
||||
&& match_field(fields[1], hour, 0, 23, schedule)?
|
||||
&& match_field(fields[2], day, 1, 31, schedule)?
|
||||
&& match_field(fields[3], month, 1, 12, schedule)?
|
||||
&& match_field(fields[4], dow, 0, 6, schedule)?)
|
||||
}
|
||||
|
||||
fn number(rfc: &str, start: usize, end: usize, schedule: &str) -> Result<u32, CronError> {
|
||||
let piece = rfc
|
||||
.get(start..end)
|
||||
.ok_or_else(|| CronError::Fields(schedule.to_string()))?;
|
||||
piece
|
||||
.parse()
|
||||
.map_err(|_| CronError::Fields(schedule.to_string()))
|
||||
}
|
||||
|
||||
fn match_field(
|
||||
field: &str,
|
||||
value: u32,
|
||||
min: u32,
|
||||
max: u32,
|
||||
schedule: &str,
|
||||
) -> Result<bool, CronError> {
|
||||
let err = || CronError::Fields(schedule.to_string());
|
||||
if field == "*" {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(step) = field.strip_prefix("*/") {
|
||||
let n: u32 = step.parse().map_err(|_| err())?;
|
||||
if n == 0 {
|
||||
return Err(err());
|
||||
}
|
||||
return Ok(value.is_multiple_of(n));
|
||||
}
|
||||
if let Some((a, b)) = field.split_once('-') {
|
||||
let lo: u32 = a.parse().map_err(|_| err())?;
|
||||
let hi: u32 = b.parse().map_err(|_| err())?;
|
||||
if lo < min || hi > max || lo > hi {
|
||||
return Err(err());
|
||||
}
|
||||
return Ok(value >= lo && value <= hi);
|
||||
}
|
||||
let n: u32 = field.parse().map_err(|_| err())?;
|
||||
if n < min || n > max {
|
||||
return Err(err());
|
||||
}
|
||||
Ok(n == value)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod broker_port;
|
||||
pub mod channel;
|
||||
pub mod compact;
|
||||
pub mod config;
|
||||
pub mod cron;
|
||||
pub mod http;
|
||||
pub mod llama;
|
||||
pub mod memory;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Tests for the five-field UTC cron matcher. Do not edit.
|
||||
|
||||
use bxcore::Timestamp;
|
||||
use loopd::cron::{self, CronError};
|
||||
|
||||
fn ts(s: &str) -> Timestamp {
|
||||
Timestamp::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn due(schedule: &str, at: &str) -> bool {
|
||||
cron::due(schedule, ts(at)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daily_hour_matches_that_minute_only() {
|
||||
assert!(due("0 7 * * *", "2026-10-03T07:00:00.000Z"));
|
||||
assert!(!due("0 7 * * *", "2026-10-03T07:01:00.000Z"));
|
||||
assert!(!due("0 7 * * *", "2026-10-03T06:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_minute_matches() {
|
||||
assert!(due("* * * * *", "2026-10-03T00:00:00.000Z"));
|
||||
assert!(due("* * * * *", "2026-10-03T23:59:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_matches_multiples_from_zero() {
|
||||
assert!(due("*/5 * * * *", "2026-10-03T00:00:00.000Z"));
|
||||
assert!(due("*/5 * * * *", "2026-10-03T00:05:00.000Z"));
|
||||
assert!(!due("*/5 * * * *", "2026-10-03T00:06:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_is_inclusive() {
|
||||
assert!(due("0-10 * * * *", "2026-10-03T00:00:00.000Z"));
|
||||
assert!(due("0-10 * * * *", "2026-10-03T00:10:00.000Z"));
|
||||
assert!(!due("0-10 * * * *", "2026-10-03T00:11:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sunday_is_zero() {
|
||||
// 2026-10-04 is a Sunday.
|
||||
assert!(due("0 7 * * 0", "2026-10-04T07:00:00.000Z"));
|
||||
// 2026-10-03 is a Saturday.
|
||||
assert!(!due("0 7 * * 0", "2026-10-03T07:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_of_the_month() {
|
||||
assert!(due("0 7 1 * *", "2026-10-01T07:00:00.000Z"));
|
||||
assert!(!due("0 7 1 * *", "2026-10-02T07:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_of_month_and_day_of_week_are_both_required() {
|
||||
// 2026-10-04 is a Sunday, not the 1st. 2026-10-01 is a Thursday.
|
||||
assert!(!due("0 7 1 * 0", "2026-10-04T07:00:00.000Z"));
|
||||
assert!(!due("0 7 1 * 0", "2026-10-01T07:00:00.000Z"));
|
||||
// 2026-11-01 is a Sunday and the 1st.
|
||||
assert!(due("0 7 1 * 0", "2026-11-01T07:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_or_six_fields_are_errors() {
|
||||
assert!(matches!(
|
||||
cron::due("* * * *", ts("2026-10-03T00:00:00.000Z")),
|
||||
Err(CronError::Fields(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cron::due("* * * * * *", ts("2026-10-03T00:00:00.000Z")),
|
||||
Err(CronError::Fields(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_out_of_range_number_is_an_error() {
|
||||
assert!(cron::due("60 * * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
|
||||
assert!(cron::due("* 24 * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
|
||||
assert!(cron::due("* * * * 7", ts("2026-10-03T00:00:00.000Z")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nicknames_are_errors() {
|
||||
let err = cron::due("@daily", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
|
||||
assert!(matches!(err, CronError::Fields(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names_the_schedule() {
|
||||
let err = cron::due("x x x x x", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("x x x x x"), "{s}");
|
||||
}
|
||||
Reference in New Issue
Block a user