cloudiful-scheduler 0.4.0

Single-job async scheduling library for background work with optional Valkey-backed state.
Documentation
use crate::error::SchedulerError;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule as ParsedCronSchedule;
use std::fmt::{self, Debug, Display, Formatter};
use std::str::FromStr;

#[derive(Clone)]
pub struct CronSchedule {
    source: String,
    parsed: ParsedCronSchedule,
}

impl CronSchedule {
    pub fn parse(expression: &str) -> Result<Self, SchedulerError> {
        validate_field_count(expression)?;

        let parsed_expression = format!("0 {expression}");
        let parsed = ParsedCronSchedule::from_str(&parsed_expression).map_err(|error| {
            SchedulerError::invalid_cron(format!("invalid cron expression `{expression}`: {error}"))
        })?;

        Ok(Self {
            source: expression.to_string(),
            parsed,
        })
    }

    pub fn as_str(&self) -> &str {
        &self.source
    }

    pub(crate) fn next_after(&self, after: DateTime<Utc>, timezone: Tz) -> Option<DateTime<Utc>> {
        let after = after.with_timezone(&timezone);
        self.parsed
            .after(&after)
            .next()
            .map(|value| value.with_timezone(&Utc))
    }
}

impl Debug for CronSchedule {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        f.debug_tuple("CronSchedule").field(&self.source).finish()
    }
}

impl Display for CronSchedule {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        f.write_str(&self.source)
    }
}

impl PartialEq for CronSchedule {
    fn eq(&self, other: &Self) -> bool {
        self.source == other.source
    }
}

impl Eq for CronSchedule {}

impl FromStr for CronSchedule {
    type Err = SchedulerError;

    fn from_str(expression: &str) -> Result<Self, Self::Err> {
        Self::parse(expression)
    }
}

impl TryFrom<&str> for CronSchedule {
    type Error = SchedulerError;

    fn try_from(expression: &str) -> Result<Self, Self::Error> {
        Self::parse(expression)
    }
}

fn validate_field_count(expression: &str) -> Result<(), SchedulerError> {
    let field_count = expression.split_whitespace().count();
    if field_count == 5 {
        return Ok(());
    }

    Err(SchedulerError::invalid_cron(format!(
        "cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got {field_count}: `{expression}`"
    )))
}

#[cfg(test)]
mod tests {
    use super::CronSchedule;
    use chrono::{TimeZone, Timelike, Utc};
    use chrono_tz::{Asia::Shanghai, UTC};

    #[test]
    fn parses_standard_five_field_expression() {
        let schedule = CronSchedule::parse("*/15 9-17 * * Mon-Fri").unwrap();

        assert_eq!(schedule.as_str(), "*/15 9-17 * * Mon-Fri");
    }

    #[test]
    fn rejects_non_five_field_expressions() {
        let error = CronSchedule::parse("0 */5 * * * *").unwrap_err();
        assert!(
            error
                .to_string()
                .contains("cron expression must have exactly 5 fields")
        );

        let error = CronSchedule::parse("@hourly").unwrap_err();
        assert!(
            error
                .to_string()
                .contains("cron expression must have exactly 5 fields")
        );
    }

    #[test]
    fn rejects_invalid_five_field_expression() {
        let error = CronSchedule::parse("bogus * * * *").unwrap_err();
        let message = error.to_string();

        assert!(message.contains("invalid cron expression `bogus * * * *`"));
    }

    #[test]
    fn next_after_uses_configured_timezone() {
        let schedule = CronSchedule::parse("0 9 * * *").unwrap();
        let start = Utc.with_ymd_and_hms(2026, 4, 3, 0, 30, 0).unwrap();

        let shanghai_next = schedule.next_after(start, Shanghai).unwrap();
        let utc_next = schedule.next_after(start, UTC).unwrap();

        assert_eq!(
            shanghai_next,
            Utc.with_ymd_and_hms(2026, 4, 3, 1, 0, 0).unwrap()
        );
        assert_eq!(utc_next, Utc.with_ymd_and_hms(2026, 4, 3, 9, 0, 0).unwrap());
    }

    #[test]
    fn next_after_advances_from_the_scheduled_time() {
        let schedule = CronSchedule::parse("* * * * *").unwrap();
        let scheduled_at = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 0).unwrap();

        let next = schedule.next_after(scheduled_at, UTC).unwrap();

        assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 3, 0).unwrap());
        assert_eq!(next.second(), 0);
    }
}