unifier-cli 0.5.0

Filesystem postbox for inter-process communication via a Unix tree
Documentation
//! Cron schedule matching for directory names like `0_0_*_*_*`.

use chrono::{Datelike, Local, Timelike};

use crate::constants::CRON_FIELD_SEP;
use crate::error::{Error, Result};

/// Five-field cron pattern: minute hour day-of-month month day-of-week.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronSchedule {
    pub minute: CronField,
    pub hour: CronField,
    pub day_of_month: CronField,
    pub month: CronField,
    pub day_of_week: CronField,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronField {
    Any,
    Exact(u32),
}

impl CronSchedule {
    /// Parse `min_hour_dom_mon_dow` directory names. `*` means any.
    pub fn parse(name: &str) -> Result<Self> {
        let parts: Vec<&str> = name.split(CRON_FIELD_SEP).collect();
        if parts.len() != 5 {
            return Err(Error::msg(format!(
                "cron schedule must have 5 fields separated by '{CRON_FIELD_SEP}', got: {name}"
            )));
        }
        Ok(Self {
            minute: parse_field(parts[0], 0, 59, "minute")?,
            hour: parse_field(parts[1], 0, 23, "hour")?,
            day_of_month: parse_field(parts[2], 1, 31, "day-of-month")?,
            month: parse_field(parts[3], 1, 12, "month")?,
            day_of_week: parse_field(parts[4], 0, 7, "day-of-week")?,
        })
    }

    pub fn matches_now(&self) -> bool {
        self.matches_at(Local::now())
    }

    pub fn matches_at<Tz: chrono::TimeZone>(&self, when: chrono::DateTime<Tz>) -> bool {
        let minute = when.minute();
        let hour = when.hour();
        let dom = when.day();
        let month = when.month();
        let dow = when.weekday().num_days_from_sunday();

        field_matches(&self.minute, minute)
            && field_matches(&self.hour, hour)
            && field_matches(&self.day_of_month, dom)
            && field_matches(&self.month, month)
            && field_matches_dow(&self.day_of_week, dow)
    }
}

fn parse_field(raw: &str, min: u32, max: u32, label: &str) -> Result<CronField> {
    if raw == "*" {
        return Ok(CronField::Any);
    }
    let n: u32 = raw.parse().map_err(|_| {
        Error::msg(format!(
            "invalid {label} in cron schedule: {raw} (use * or {min}-{max})"
        ))
    })?;
    if n < min || n > max {
        return Err(Error::msg(format!(
            "{label} out of range ({min}-{max}): {n}"
        )));
    }
    Ok(CronField::Exact(n))
}

fn field_matches(field: &CronField, value: u32) -> bool {
    match field {
        CronField::Any => true,
        CronField::Exact(n) => *n == value,
    }
}

/// Sunday is 0 or 7 in traditional cron.
fn field_matches_dow(field: &CronField, value: u32) -> bool {
    match field {
        CronField::Any => true,
        CronField::Exact(n) => *n == value || (*n == 7 && value == 0),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;

    #[test]
    fn parse_wildcard_schedule() {
        let s = CronSchedule::parse("*_*_*_*_*").unwrap();
        assert_eq!(s.minute, CronField::Any);
    }

    #[test]
    fn matches_specific_minute() {
        let s = CronSchedule::parse("30_14_*_*_*").unwrap();
        let when = Local.with_ymd_and_hms(2026, 6, 2, 14, 30, 0).unwrap();
        assert!(s.matches_at(when));
        let other = Local.with_ymd_and_hms(2026, 6, 2, 14, 31, 0).unwrap();
        assert!(!s.matches_at(other));
    }
}