pollen-scheduler 0.1.0

Task scheduler for Pollen
Documentation
//! Cron expression parsing.

use chrono::{DateTime, Utc, Datelike, Timelike};
use pollen_types::{PollenError, Result};

/// Parsed cron expression.
#[derive(Clone, Debug)]
pub struct CronExpr {
    minutes: Vec<u32>,
    hours: Vec<u32>,
    days_of_month: Vec<u32>,
    months: Vec<u32>,
    days_of_week: Vec<u32>,
}

/// Parse a cron expression.
///
/// Format: "minute hour day-of-month month day-of-week"
/// Supports: *, ranges (1-5), lists (1,2,3), steps (*/5)
pub fn parse_cron(expr: &str) -> Result<CronExpr> {
    let parts: Vec<&str> = expr.split_whitespace().collect();

    if parts.len() != 5 {
        return Err(PollenError::InvalidCron(format!(
            "expected 5 fields, got {}",
            parts.len()
        )));
    }

    Ok(CronExpr {
        minutes: parse_field(parts[0], 0, 59)?,
        hours: parse_field(parts[1], 0, 23)?,
        days_of_month: parse_field(parts[2], 1, 31)?,
        months: parse_field(parts[3], 1, 12)?,
        days_of_week: parse_field(parts[4], 0, 6)?,
    })
}

fn parse_field(field: &str, min: u32, max: u32) -> Result<Vec<u32>> {
    let mut values = Vec::new();

    for part in field.split(',') {
        if part == "*" {
            values.extend(min..=max);
        } else if part.contains('/') {
            // Step values: */5 or 0-30/5
            let parts: Vec<&str> = part.split('/').collect();
            if parts.len() != 2 {
                return Err(PollenError::InvalidCron(format!("invalid step: {}", part)));
            }

            let step: u32 = parts[1]
                .parse()
                .map_err(|_| PollenError::InvalidCron(format!("invalid step: {}", parts[1])))?;

            let (start, end) = if parts[0] == "*" {
                (min, max)
            } else if parts[0].contains('-') {
                parse_range(parts[0], min, max)?
            } else {
                let start: u32 = parts[0]
                    .parse()
                    .map_err(|_| PollenError::InvalidCron(format!("invalid value: {}", parts[0])))?;
                (start, max)
            };

            let mut v = start;
            while v <= end {
                values.push(v);
                v += step;
            }
        } else if part.contains('-') {
            // Range: 1-5
            let (start, end) = parse_range(part, min, max)?;
            values.extend(start..=end);
        } else {
            // Single value
            let v: u32 = part
                .parse()
                .map_err(|_| PollenError::InvalidCron(format!("invalid value: {}", part)))?;

            if v < min || v > max {
                return Err(PollenError::InvalidCron(format!(
                    "value {} out of range {}-{}",
                    v, min, max
                )));
            }

            values.push(v);
        }
    }

    values.sort();
    values.dedup();

    if values.is_empty() {
        return Err(PollenError::InvalidCron("empty field".to_string()));
    }

    Ok(values)
}

fn parse_range(s: &str, min: u32, max: u32) -> Result<(u32, u32)> {
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 2 {
        return Err(PollenError::InvalidCron(format!("invalid range: {}", s)));
    }

    let start: u32 = parts[0]
        .parse()
        .map_err(|_| PollenError::InvalidCron(format!("invalid value: {}", parts[0])))?;
    let end: u32 = parts[1]
        .parse()
        .map_err(|_| PollenError::InvalidCron(format!("invalid value: {}", parts[1])))?;

    if start < min || end > max || start > end {
        return Err(PollenError::InvalidCron(format!(
            "range {}-{} out of bounds {}-{}",
            start, end, min, max
        )));
    }

    Ok((start, end))
}

impl CronExpr {
    /// Find the next occurrence after the given time.
    pub fn find_next_occurrence(&self, after: &DateTime<Utc>, inclusive: bool) -> Result<DateTime<Utc>> {
        let mut dt = if inclusive {
            *after
        } else {
            *after + chrono::Duration::minutes(1)
        };

        // Reset seconds to 0
        dt = dt.with_second(0).unwrap().with_nanosecond(0).unwrap();

        // Search for up to 4 years
        let max_iterations = 366 * 24 * 60 * 4;

        for _ in 0..max_iterations {
            // Check month
            if !self.months.contains(&dt.month()) {
                dt = next_month(dt);
                continue;
            }

            // Check day of month
            if !self.days_of_month.contains(&dt.day()) {
                dt = next_day(dt);
                continue;
            }

            // Check day of week
            let dow = dt.weekday().num_days_from_sunday();
            if !self.days_of_week.contains(&dow) {
                dt = next_day(dt);
                continue;
            }

            // Check hour
            if !self.hours.contains(&dt.hour()) {
                dt = next_hour(dt);
                continue;
            }

            // Check minute
            if !self.minutes.contains(&dt.minute()) {
                dt += chrono::Duration::minutes(1);
                continue;
            }

            return Ok(dt);
        }

        Err(PollenError::InvalidCron("no matching time found".to_string()))
    }
}

fn next_month(dt: DateTime<Utc>) -> DateTime<Utc> {
    if dt.month() == 12 {
        dt.with_year(dt.year() + 1)
            .unwrap()
            .with_month(1)
            .unwrap()
            .with_day(1)
            .unwrap()
            .with_hour(0)
            .unwrap()
            .with_minute(0)
            .unwrap()
    } else {
        dt.with_month(dt.month() + 1)
            .unwrap()
            .with_day(1)
            .unwrap()
            .with_hour(0)
            .unwrap()
            .with_minute(0)
            .unwrap()
    }
}

fn next_day(dt: DateTime<Utc>) -> DateTime<Utc> {
    (dt + chrono::Duration::days(1))
        .with_hour(0)
        .unwrap()
        .with_minute(0)
        .unwrap()
}

fn next_hour(dt: DateTime<Utc>) -> DateTime<Utc> {
    (dt + chrono::Duration::hours(1))
        .with_minute(0)
        .unwrap()
}

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

    #[test]
    fn test_parse_every_minute() {
        let cron = parse_cron("* * * * *").unwrap();
        assert_eq!(cron.minutes.len(), 60);
        assert_eq!(cron.hours.len(), 24);
    }

    #[test]
    fn test_parse_specific_time() {
        let cron = parse_cron("30 9 * * *").unwrap();
        assert_eq!(cron.minutes, vec![30]);
        assert_eq!(cron.hours, vec![9]);
    }

    #[test]
    fn test_parse_step() {
        let cron = parse_cron("*/15 * * * *").unwrap();
        assert_eq!(cron.minutes, vec![0, 15, 30, 45]);
    }

    #[test]
    fn test_parse_range() {
        let cron = parse_cron("0 9-17 * * *").unwrap();
        assert_eq!(cron.hours, vec![9, 10, 11, 12, 13, 14, 15, 16, 17]);
    }

    #[test]
    fn test_parse_list() {
        let cron = parse_cron("0 9,12,18 * * *").unwrap();
        assert_eq!(cron.hours, vec![9, 12, 18]);
    }

    #[test]
    fn test_find_next() {
        let cron = parse_cron("0 9 * * *").unwrap();
        let now = Utc::now().with_hour(8).unwrap().with_minute(30).unwrap();
        let next = cron.find_next_occurrence(&now, false).unwrap();

        assert_eq!(next.hour(), 9);
        assert_eq!(next.minute(), 0);
    }

    #[test]
    fn test_invalid_cron() {
        assert!(parse_cron("* * *").is_err()); // Not enough fields
        assert!(parse_cron("60 * * * *").is_err()); // Minute out of range
    }
}