use chrono::{DateTime, Utc, Datelike, Timelike};
use pollen_types::{PollenError, Result};
#[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>,
}
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('/') {
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('-') {
let (start, end) = parse_range(part, min, max)?;
values.extend(start..=end);
} else {
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 {
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)
};
dt = dt.with_second(0).unwrap().with_nanosecond(0).unwrap();
let max_iterations = 366 * 24 * 60 * 4;
for _ in 0..max_iterations {
if !self.months.contains(&dt.month()) {
dt = next_month(dt);
continue;
}
if !self.days_of_month.contains(&dt.day()) {
dt = next_day(dt);
continue;
}
let dow = dt.weekday().num_days_from_sunday();
if !self.days_of_week.contains(&dow) {
dt = next_day(dt);
continue;
}
if !self.hours.contains(&dt.hour()) {
dt = next_hour(dt);
continue;
}
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()); assert!(parse_cron("60 * * * *").is_err()); }
}