#![cfg_attr(docsrs, feature(doc_cfg))]
pub mod describe;
pub mod errors;
pub mod parser;
pub mod time;
mod component;
mod iterator;
mod pattern;
#[derive(Clone, Copy, Debug, PartialOrd, Ord, PartialEq, Eq, Hash)]
pub enum Direction {
Forward,
Backward,
}
impl Direction {
pub(crate) const fn step(self) -> i64 {
match self {
Direction::Forward => 1,
Direction::Backward => -1,
}
}
pub(crate) const fn reset_time(self) -> CivilTime {
match self {
Direction::Forward => CivilTime::MIDNIGHT,
Direction::Backward => CivilTime::END_OF_DAY,
}
}
fn precedes<T: CronDateTime>(self, a: &T, b: &T) -> bool {
match self {
Direction::Forward => a.cmp_instant(b).is_lt(),
Direction::Backward => a.cmp_instant(b).is_gt(),
}
}
}
#[derive(PartialEq, Eq, Ord, PartialOrd, Hash, Clone, Copy, Debug)]
pub enum TimeComponent {
Second = 1,
Minute,
Hour,
Day,
Month,
Year,
}
#[derive(Debug, PartialEq, Eq)]
pub enum JobType {
FixedTime,
IntervalWildcard,
}
use errors::CronError;
pub use iterator::CronIterator;
use parser::CronParser;
use pattern::CronPattern;
use std::str::FromStr;
use time::{fold_of, other_fold_edge, Cursor, Fold};
pub use time::{CivilDate, CivilDateTime, CivilTime, CronDateTime, Resolution, Weekday};
#[cfg(feature = "serde")]
use core::fmt;
#[cfg(feature = "serde")]
use serde::{
de::{self, Visitor},
Deserialize, Serialize, Serializer,
};
pub const YEAR_UPPER_LIMIT: i32 = 5000;
pub const YEAR_LOWER_LIMIT: i32 = 1;
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Hash)]
pub struct Cron {
pub pattern: CronPattern, }
impl FromStr for Cron {
type Err = CronError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
CronParser::new().parse(s)
}
}
impl Cron {
pub fn is_time_matching<T: CronDateTime>(&self, time: &T) -> Result<bool, CronError> {
self.is_cursor_matching(Cursor::new(time))
}
fn is_cursor_matching(&self, cursor: Cursor) -> Result<bool, CronError> {
Ok(self.pattern.second_match(cursor.second())?
&& self.pattern.minute_match(cursor.minute())?
&& self.pattern.hour_match(cursor.hour())?
&& self.pattern.day_match(
cursor.year(),
cursor.month(),
cursor.day(),
cursor.weekday(),
)?
&& self.pattern.month_match(cursor.month())?
&& self.pattern.year_match(cursor.year())?) }
pub fn find_next_occurrence<T: CronDateTime>(
&self,
start_time: &T,
inclusive: bool,
) -> Result<T, CronError> {
self.find_occurrence(start_time, inclusive, Direction::Forward)
}
pub fn find_previous_occurrence<T: CronDateTime>(
&self,
start_time: &T,
inclusive: bool,
) -> Result<T, CronError> {
self.find_occurrence(start_time, inclusive, Direction::Backward)
}
fn find_occurrence<T: CronDateTime>(
&self,
start_time: &T,
inclusive: bool,
direction: Direction,
) -> Result<T, CronError> {
let fixed_time = self.determine_job_type() == JobType::FixedTime;
let first_half = match direction {
Direction::Forward => Fold::Earlier,
Direction::Backward => Fold::Later,
};
let in_fold = self.walk(start_time, inclusive, direction, fixed_time)?;
if fixed_time && direction == Direction::Forward {
return Ok(in_fold);
}
if fold_of(start_time)? != Some(first_half) {
return Ok(in_fold);
}
let other_edge = other_fold_edge(start_time, first_half)?;
if direction.precedes(&in_fold, &other_edge) {
return Ok(in_fold);
}
let in_other_fold = self.walk(&other_edge, true, direction, fixed_time)?;
Ok(if direction.precedes(&in_other_fold, &in_fold) {
in_other_fold
} else {
in_fold
})
}
fn walk<T: CronDateTime>(
&self,
origin: &T,
inclusive: bool,
direction: Direction,
fixed_time: bool,
) -> Result<T, CronError> {
let mut cursor = Cursor::new(origin);
if !inclusive {
cursor = cursor
.checked_add_seconds(direction.step())
.ok_or(CronError::InvalidTime)?;
}
let mut iterations = 0;
const MAX_SEARCH_ITERATIONS: u32 = 366 * 24 * 60 * 60;
loop {
iterations += 1;
if iterations > MAX_SEARCH_ITERATIONS {
return Err(CronError::TimeSearchLimitExceeded);
}
let mut changed_component_in_this_pass = false;
changed_component_in_this_pass |=
self.find_matching_date_component(&mut cursor, direction, TimeComponent::Year)?;
if !changed_component_in_this_pass {
changed_component_in_this_pass |= self.find_matching_date_component(
&mut cursor,
direction,
TimeComponent::Month,
)?;
}
if !changed_component_in_this_pass {
changed_component_in_this_pass |=
self.find_matching_date_component(&mut cursor, direction, TimeComponent::Day)?;
}
if changed_component_in_this_pass {
cursor = cursor.with_time(match direction {
Direction::Forward => CivilTime::MIDNIGHT,
Direction::Backward => CivilTime::END_OF_DAY,
});
}
let mut time_component_adjusted_in_this_pass = false;
time_component_adjusted_in_this_pass |=
self.find_matching_granular_component(&mut cursor, direction, TimeComponent::Hour)?;
if !time_component_adjusted_in_this_pass {
time_component_adjusted_in_this_pass |= self.find_matching_granular_component(
&mut cursor,
direction,
TimeComponent::Minute,
)?;
}
if !time_component_adjusted_in_this_pass {
self.find_matching_granular_component(
&mut cursor,
direction,
TimeComponent::Second,
)?;
}
match cursor.resolve_in(origin)? {
Resolution::Single(dt) => {
debug_assert_eq!(
dt.to_civil(),
cursor.civil(),
"CronDateTime::resolve_civil must return the wall clock time it was given"
);
if self.is_cursor_matching(cursor)? {
return Ok(dt);
}
cursor = cursor
.checked_add_seconds(direction.step())
.ok_or(CronError::InvalidTime)?;
}
Resolution::Ambiguous(earlier, later) => {
debug_assert_eq!(
earlier.to_civil(),
cursor.civil(),
"CronDateTime::resolve_civil must return the wall clock time it was given"
);
if self.is_cursor_matching(cursor)? {
let behind = |instant: &T| -> Result<bool, CronError> {
let nudged = instant
.checked_add_seconds(direction.step())
.ok_or(CronError::InvalidTime)?;
Ok(!direction.precedes(origin, &nudged))
};
if fixed_time {
if !behind(&earlier)? {
return Ok(earlier);
}
} else {
let (near, far) = match direction {
Direction::Forward => (earlier, later),
Direction::Backward => (later, earlier),
};
if !behind(&near)? {
return Ok(near);
}
if !behind(&far)? {
return Ok(far);
}
}
}
cursor = cursor
.checked_add_seconds(direction.step())
.ok_or(CronError::InvalidTime)?;
}
Resolution::Gap => {
if fixed_time {
let mut after_gap = cursor;
let mut before_gap = cursor;
let mut gap_adjust_count = 0;
const MAX_GAP_SEARCH_SECONDS: u32 = 3600 * 2;
let resolved_dt_after_gap: T;
loop {
after_gap = after_gap
.checked_add_seconds(1)
.ok_or(CronError::InvalidTime)?;
gap_adjust_count += 1;
if gap_adjust_count > MAX_GAP_SEARCH_SECONDS {
return Err(CronError::TimeSearchLimitExceeded);
}
match after_gap.resolve_in(origin)? {
Resolution::Single(dt) => {
resolved_dt_after_gap = dt;
break;
}
Resolution::Ambiguous(earlier, _) => {
resolved_dt_after_gap = earlier;
break;
}
Resolution::Gap => {}
}
}
if self.pattern.day_match(
after_gap.year(),
after_gap.month(),
after_gap.day(),
after_gap.weekday(),
)? && self.pattern.month_match(after_gap.month())?
&& self.pattern.year_match(after_gap.year())?
{
let matches_direction = match resolved_dt_after_gap.cmp_instant(origin)
{
core::cmp::Ordering::Less => direction == Direction::Backward,
core::cmp::Ordering::Equal => inclusive,
core::cmp::Ordering::Greater => direction == Direction::Forward,
};
if matches_direction {
return Ok(resolved_dt_after_gap);
}
}
if direction == Direction::Backward {
gap_adjust_count = 0;
loop {
before_gap = before_gap
.checked_add_seconds(-1)
.ok_or(CronError::InvalidTime)?;
gap_adjust_count += 1;
if gap_adjust_count > MAX_GAP_SEARCH_SECONDS {
return Err(CronError::TimeSearchLimitExceeded);
}
match before_gap.resolve_in(origin)? {
Resolution::Single(_) | Resolution::Ambiguous(_, _) => {
cursor = before_gap;
break;
}
Resolution::Gap => {}
}
}
} else {
cursor = after_gap;
}
} else {
cursor = cursor
.checked_add_seconds(direction.step())
.ok_or(CronError::InvalidTime)?;
}
}
}
}
}
pub fn iter_from<T: CronDateTime>(
&self,
start_from: T,
direction: Direction,
) -> CronIterator<T> {
CronIterator::new(self.clone(), start_from, true, direction)
}
pub fn iter_after<T: CronDateTime>(&self, start_after: T) -> CronIterator<T> {
CronIterator::new(self.clone(), start_after, false, Direction::Forward)
}
pub fn iter_before<T: CronDateTime>(&self, start_before: T) -> CronIterator<T> {
CronIterator::new(self.clone(), start_before, false, Direction::Backward)
}
pub fn describe(&self) -> String {
self.pattern.describe()
}
pub fn describe_lang<L: crate::describe::Language>(&self, lang: L) -> String {
self.pattern.describe_lang(lang)
}
pub fn determine_job_type(&self) -> JobType {
let is_fixed = |field: &component::CronComponent| {
field.step == 1
&& !field.from_wildcard
&& field.count_set_values(component::ALL_BIT) == 1
};
if is_fixed(&self.pattern.seconds)
&& is_fixed(&self.pattern.minutes)
&& is_fixed(&self.pattern.hours)
{
JobType::FixedTime
} else {
JobType::IntervalWildcard
}
}
fn set_time_component(
cursor: &mut Cursor,
component: TimeComponent,
value: u32,
direction: Direction,
) -> Result<(), CronError> {
let time = match component {
TimeComponent::Second => CivilTime::from_hms_opt(cursor.hour(), cursor.minute(), value),
TimeComponent::Minute => CivilTime::from_hms_opt(cursor.hour(), value, cursor.second()),
TimeComponent::Hour => CivilTime::from_hms_opt(value, cursor.minute(), cursor.second()),
_ => return Err(CronError::InvalidTime),
}
.ok_or(CronError::InvalidTime)?;
*cursor = cursor.with_time(Self::reset_lower_components(time, component, direction));
Ok(())
}
fn adjust_time_component(
cursor: &mut Cursor,
component: TimeComponent,
direction: Direction,
) -> Result<(), CronError> {
match direction {
Direction::Forward if cursor.year() >= YEAR_UPPER_LIMIT => {
return Err(CronError::TimeSearchLimitExceeded)
}
Direction::Backward if cursor.year() <= YEAR_LOWER_LIMIT => {
return Err(CronError::TimeSearchLimitExceeded)
}
_ => {}
}
if matches!(component, TimeComponent::Hour | TimeComponent::Minute) {
let step_seconds = if component == TimeComponent::Hour {
3600
} else {
60
};
let stepped = cursor
.checked_add_seconds(step_seconds * direction.step())
.ok_or(CronError::InvalidTime)?;
*cursor = stepped.with_time(Self::reset_lower_components(
stepped.time(),
component,
direction,
));
return Ok(());
}
*cursor = match (component, direction) {
(TimeComponent::Year, Direction::Forward) => cursor.start_of_next_year(),
(TimeComponent::Year, Direction::Backward) => cursor.end_of_previous_year(),
(TimeComponent::Month, Direction::Forward) => cursor.start_of_next_month(),
(TimeComponent::Month, Direction::Backward) => cursor.end_of_previous_month(),
(TimeComponent::Day, _) => cursor
.checked_add_days(direction.step())
.map(|stepped| stepped.with_time(direction.reset_time())),
_ => return Err(CronError::InvalidTime),
}
.ok_or(CronError::InvalidDate)?;
Ok(())
}
fn reset_lower_components(
time: CivilTime,
component: TimeComponent,
direction: Direction,
) -> CivilTime {
let reset = direction.reset_time();
CivilTime::from_parts_unchecked(
if component >= TimeComponent::Day {
reset.hour()
} else {
time.hour()
},
if component >= TimeComponent::Hour {
reset.minute()
} else {
time.minute()
},
if component >= TimeComponent::Minute {
reset.second()
} else {
time.second()
},
)
}
fn find_matching_date_component(
&self,
cursor: &mut Cursor,
direction: Direction,
component: TimeComponent,
) -> Result<bool, CronError> {
let mut changed = false;
while !(match component {
TimeComponent::Year => self.pattern.year_match(cursor.year()), TimeComponent::Month => self.pattern.month_match(cursor.month()),
TimeComponent::Day => self.pattern.day_match(
cursor.year(),
cursor.month(),
cursor.day(),
cursor.weekday(),
),
_ => Ok(true), })? {
Self::adjust_time_component(cursor, component, direction)?;
changed = true;
}
Ok(changed)
}
fn find_matching_granular_component(
&self,
cursor: &mut Cursor,
direction: Direction,
component: TimeComponent,
) -> Result<bool, CronError> {
let mut changed = false;
let (current_value, next_larger_component) = match component {
TimeComponent::Hour => (cursor.hour(), TimeComponent::Day),
TimeComponent::Minute => (cursor.minute(), TimeComponent::Hour),
TimeComponent::Second => (cursor.second(), TimeComponent::Minute),
_ => return Err(CronError::InvalidTime),
};
let match_result =
self.pattern
.find_match_in_component(current_value, component, direction)?;
match match_result {
Some(match_value) => {
if match_value != current_value {
Self::set_time_component(cursor, component, match_value, direction)?;
}
}
None => {
Self::adjust_time_component(cursor, next_larger_component, direction)?;
changed = true;
}
}
Ok(changed)
}
pub fn as_str(&self) -> &str {
self.pattern.as_str()
}
}
impl std::fmt::Display for Cron {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.pattern)
}
}
#[cfg(feature = "serde")]
impl Serialize for Cron {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.pattern.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for Cron {
fn deserialize<D>(deserializer: D) -> Result<Cron, D::Error>
where
D: de::Deserializer<'de>,
{
struct CronVisitor;
impl Visitor<'_> for CronVisitor {
type Value = Cron;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a valid cron pattern")
}
fn visit_str<E>(self, value: &str) -> Result<Cron, E>
where
E: de::Error,
{
Cron::from_str(value).map_err(de::Error::custom)
}
}
deserializer.deserialize_str(CronVisitor)
}
}
#[cfg(feature = "chrono")]
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[deprecated(
since = "4.0.0",
note = "call `chrono::TimeZone::from_local_datetime` directly"
)]
pub fn from_naive<Tz: chrono::TimeZone>(
naive_time: chrono::NaiveDateTime,
timezone: &Tz,
) -> chrono::LocalResult<chrono::DateTime<Tz>> {
chrono::TimeZone::from_local_datetime(timezone, &naive_time)
}
#[cfg(all(test, feature = "chrono"))]
mod tests {
use std::hash::{DefaultHasher, Hash, Hasher as _};
use crate::parser::Seconds;
use super::*;
use chrono::{Datelike as _, Local, TimeZone, Timelike as _};
use chrono_tz::Tz;
use rstest::rstest;
#[test]
fn test_is_time_matching() -> Result<(), CronError> {
let cron = Cron::from_str("0 9 1 1 *")?;
let time_matching = Local.with_ymd_and_hms(2023, 1, 1, 9, 0, 0).unwrap();
let time_not_matching = Local.with_ymd_and_hms(2023, 1, 1, 10, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
assert!(!cron.is_time_matching(&time_not_matching)?);
Ok(())
}
#[test]
fn test_last_day_of_february_non_leap_year() -> Result<(), CronError> {
let cron = Cron::from_str("0 9 L 2 *")?;
let time_matching = Local.with_ymd_and_hms(2023, 2, 28, 9, 0, 0).unwrap();
let time_not_matching = Local.with_ymd_and_hms(2023, 2, 28, 10, 0, 0).unwrap();
let time_not_matching_2 = Local.with_ymd_and_hms(2023, 2, 27, 9, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
assert!(!cron.is_time_matching(&time_not_matching)?);
assert!(!cron.is_time_matching(&time_not_matching_2)?);
Ok(())
}
#[test]
fn test_last_day_of_february_leap_year() -> Result<(), CronError> {
let cron = Cron::from_str("0 9 L 2 *")?;
let time_matching = Local.with_ymd_and_hms(2024, 2, 29, 9, 0, 0).unwrap();
let time_not_matching = Local.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap();
let time_not_matching_2 = Local.with_ymd_and_hms(2024, 2, 28, 9, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
assert!(!cron.is_time_matching(&time_not_matching)?);
assert!(!cron.is_time_matching(&time_not_matching_2)?);
Ok(())
}
#[test]
fn test_last_friday_of_year() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * * FRI#L")?;
let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
Ok(())
}
#[test]
fn test_last_friday_of_year_alternative_alpha_syntax() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * * FRIl")?;
let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
Ok(())
}
#[test]
fn test_last_friday_of_year_alternative_number_syntax() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * * 5L")?;
let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&time_matching)?);
Ok(())
}
#[test]
fn test_find_next_occurrence() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* * * * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 29).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 30).unwrap();
assert_eq!(next_occurrence, expected_time);
Ok(())
}
#[test]
fn test_find_next_minute() -> Result<(), CronError> {
let cron = Cron::from_str("* * * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 29).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 1, 0).unwrap();
assert_eq!(next_occurrence, expected_time);
Ok(())
}
#[test]
fn test_wrap_month_and_year() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 15 * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 12, 31, 16, 0, 0).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2024, 1, 1, 15, 0, 0).unwrap();
assert_eq!(next_occurrence, expected_time);
Ok(())
}
#[test]
fn test_weekday_pattern_correct_weekdays() -> Result<(), CronError> {
let schedule = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 * * 5,6")?;
let start_time = Local
.with_ymd_and_hms(2022, 2, 17, 0, 0, 0)
.single()
.unwrap();
let mut next_runs = Vec::new();
for next in schedule.iter_after(start_time).take(6) {
next_runs.push(next);
}
assert_eq!(next_runs[0].year(), 2022);
assert_eq!(next_runs[0].month(), 2);
assert_eq!(next_runs[0].day(), 18);
assert_eq!(next_runs[1].day(), 19);
assert_eq!(next_runs[2].day(), 25);
assert_eq!(next_runs[3].day(), 26);
assert_eq!(next_runs[4].month(), 3);
assert_eq!(next_runs[4].day(), 4);
assert_eq!(next_runs[5].day(), 5);
Ok(())
}
#[test]
fn test_weekday_pattern_combined_with_day_of_month() -> Result<(), CronError> {
let schedule = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("59 59 23 2 * 6")?;
let start_time = Local
.with_ymd_and_hms(2022, 1, 31, 0, 0, 0)
.single()
.unwrap();
let mut next_runs = Vec::new();
for next in schedule.iter_after(start_time).take(6) {
next_runs.push(next);
}
assert_eq!(next_runs[0].year(), 2022);
assert_eq!(next_runs[0].month(), 2);
assert_eq!(next_runs[0].day(), 2);
assert_eq!(next_runs[1].month(), 2);
assert_eq!(next_runs[1].day(), 5);
assert_eq!(next_runs[2].month(), 2);
assert_eq!(next_runs[2].day(), 12);
assert_eq!(next_runs[3].month(), 2);
assert_eq!(next_runs[3].day(), 19);
assert_eq!(next_runs[4].month(), 2);
assert_eq!(next_runs[4].day(), 26);
assert_eq!(next_runs[5].month(), 3);
assert_eq!(next_runs[5].day(), 2);
Ok(())
}
#[test]
fn test_weekday_pattern_alone() -> Result<(), CronError> {
let schedule = Cron::from_str("15 9 * * mon")?;
let start_time = Local
.with_ymd_and_hms(2022, 2, 28, 23, 59, 0)
.single()
.unwrap();
let mut next_runs = Vec::new();
for next in schedule.iter_after(start_time).take(3) {
next_runs.push(next);
}
assert_eq!(next_runs[0].year(), 2022);
assert_eq!(next_runs[0].month(), 3);
assert_eq!(next_runs[0].day(), 7);
assert_eq!(next_runs[0].hour(), 9);
assert_eq!(next_runs[0].minute(), 15);
assert_eq!(next_runs[1].day(), 14);
assert_eq!(next_runs[1].hour(), 9);
assert_eq!(next_runs[1].minute(), 15);
assert_eq!(next_runs[2].day(), 21);
assert_eq!(next_runs[2].hour(), 9);
assert_eq!(next_runs[2].minute(), 15);
Ok(())
}
#[test]
fn test_cron_expression_13w_wed() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 13W * WED")?;
let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let expected_dates = [
Local.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 10, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 12, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 17, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 24, 0, 0, 0).unwrap(),
];
for (idx, current_date) in cron
.clone()
.iter_from(start_date, Direction::Forward)
.take(5)
.enumerate()
{
assert_eq!(expected_dates[idx], current_date);
}
Ok(())
}
#[test]
fn test_cron_expression_31dec_fri() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Required)
.dom_and_dow(true)
.build()
.parse("0 0 0 31 12 FRI")?;
let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let expected_dates = [
Local.with_ymd_and_hms(2027, 12, 31, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2032, 12, 31, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2038, 12, 31, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2049, 12, 31, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2055, 12, 31, 0, 0, 0).unwrap(),
];
for (idx, current_date) in cron
.clone()
.iter_from(start_date, Direction::Forward)
.take(5)
.enumerate()
{
assert_eq!(expected_dates[idx], current_date);
}
Ok(())
}
#[test]
fn test_cron_parse_invalid_expressions() {
let invalid_expressions = vec![
"* * *",
"invalid",
"123",
"0 0 * * * * * *",
"* * * *",
"* 60 * * * *",
"-1 59 * * * *",
"1- 59 * * * *",
"0 0 0 5L * *",
"0 0 0 5#L * *",
];
for expr in invalid_expressions {
assert!(CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse(expr)
.is_err());
}
}
#[test]
fn test_cron_parse_valid_expressions() {
let valid_expressions = vec![
"* * * * *",
"0 0 * * *",
"*/10 * * * *",
"0 0 1 1 *",
"0 12 * * MON",
"0 0 * * 1",
"0 0 1 1,7 * ",
"00 00 01 * SUN ",
"0 0 1-7 * SUN",
"5-10/2 * * * *",
"0 0-23/2 * * *",
"0 12 15-21 * 1-FRI",
"0 0 29 2 *",
"0 0 31 * *",
"*/15 9-17 * * MON-FRI",
"0 12 * JAN-JUN *",
"0 0 1,15,L * SUN#L",
"0 0 2,1 1-6/2 *",
"0 0 5,L * 5L",
"0 0 5,L * 7#2",
];
for expr in valid_expressions {
assert!(Cron::from_str(expr).is_ok());
}
}
#[test]
fn test_is_time_matching_different_time_zones() -> Result<(), CronError> {
use chrono::FixedOffset;
let cron = Cron::from_str("0 12 * * *")?;
let time_east_matching = FixedOffset::east_opt(3600)
.expect("Success")
.with_ymd_and_hms(2023, 1, 1, 12, 0, 0)
.unwrap(); let time_west_matching = FixedOffset::west_opt(3600)
.expect("Success")
.with_ymd_and_hms(2023, 1, 1, 12, 0, 0)
.unwrap();
assert!(cron.is_time_matching(&time_east_matching)?);
assert!(cron.is_time_matching(&time_west_matching)?);
Ok(())
}
#[test]
fn test_find_next_occurrence_edge_case_inclusive() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("59 59 23 * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, true)?;
let expected_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap();
assert_eq!(next_occurrence, expected_time);
Ok(())
}
#[test]
fn test_find_next_occurrence_edge_case_exclusive() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("59 59 23 * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2023, 3, 15, 23, 59, 59).unwrap();
assert_eq!(next_occurrence, expected_time);
Ok(())
}
#[test]
fn test_cron_iterator_large_time_jumps() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * * *")?;
let start_time = Local.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let mut iterator = cron.iter_after(start_time);
let next_run = iterator.nth(365 * 5 + 1); let expected_time = Local.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
assert_eq!(next_run, Some(expected_time));
Ok(())
}
#[test]
fn test_handling_different_month_lengths() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 L * *")?; let feb_non_leap_year = Local.with_ymd_and_hms(2023, 2, 1, 0, 0, 0).unwrap();
let feb_leap_year = Local.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap();
let april = Local.with_ymd_and_hms(2023, 4, 1, 0, 0, 0).unwrap();
assert_eq!(
cron.find_next_occurrence(&feb_non_leap_year, false)?,
Local.with_ymd_and_hms(2023, 2, 28, 0, 0, 0).unwrap()
);
assert_eq!(
cron.find_next_occurrence(&feb_leap_year, false)?,
Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap()
);
assert_eq!(
cron.find_next_occurrence(&april, false)?,
Local.with_ymd_and_hms(2023, 4, 30, 0, 0, 0).unwrap()
);
Ok(())
}
#[test]
fn test_cron_iterator_non_standard_intervals() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("*/29 */13 * * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let mut iterator = cron.iter_after(start_time);
let first_run = iterator.next().unwrap();
let second_run = iterator.next().unwrap();
assert_eq!(first_run.hour() % 13, 0);
assert_eq!(first_run.minute() % 29, 0);
assert_eq!(second_run.hour() % 13, 0);
assert_eq!(second_run.minute() % 29, 0);
Ok(())
}
#[test]
fn test_cron_iterator_non_standard_intervals_with_offset() -> Result<(), CronError> {
let cron = Cron::from_str("7-59/29 2-23/13 * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let mut iterator = cron.iter_after(start_time);
let first_run = iterator.next().unwrap();
assert_eq!(first_run.hour(), 2);
assert_eq!(first_run.minute(), 7);
let second_run = iterator.next().unwrap();
assert_eq!(second_run.hour(), 2);
assert_eq!(second_run.minute(), 36);
Ok(())
}
#[test]
fn test_unusual_cron_expression_end_month_start_month_mon() -> Result<(), CronError> {
use chrono::TimeZone;
let cron = Cron::from_str("0 0 */31,1-7 */1 MON")?;
let start_date = Local.with_ymd_and_hms(2023, 12, 24, 0, 0, 0).unwrap();
let expected_dates = vec![
Local.with_ymd_and_hms(2023, 12, 25, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 4, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 5, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 6, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 7, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 8, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 22, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 1, 29, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap(),
];
let mut idx = 0;
for current_date in cron
.iter_from(start_date, Direction::Forward)
.take(expected_dates.len())
{
assert_eq!(expected_dates[idx], current_date);
idx += 1;
}
assert_eq!(idx, 13);
Ok(())
}
#[test]
fn test_unusual_cron_expression_end_month_start_month_mon_dom_and_dow() -> Result<(), CronError>
{
use chrono::TimeZone;
let cron = CronParser::builder()
.seconds(Seconds::Optional) .dom_and_dow(true)
.build()
.parse("0 0 */31,1-7 */1 MON")?;
let start_date = Local.with_ymd_and_hms(2023, 12, 24, 0, 0, 0).unwrap();
let expected_dates = [
Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 2, 5, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 3, 4, 0, 0, 0).unwrap(),
];
let mut idx = 0;
for current_date in cron
.iter_from(start_date, Direction::Forward)
.take(expected_dates.len())
{
assert_eq!(expected_dates[idx], current_date);
idx += 1;
}
assert_eq!(idx, 3);
Ok(())
}
#[test]
fn test_cron_expression_29feb_march_fri() -> Result<(), CronError> {
use chrono::TimeZone;
let cron = CronParser::builder()
.seconds(Seconds::Optional) .dom_and_dow(true)
.build()
.parse("0 0 29 2-3 FRI")?;
let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let expected_dates = [
Local.with_ymd_and_hms(2024, 3, 29, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2030, 3, 29, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2036, 2, 29, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2041, 3, 29, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2047, 3, 29, 0, 0, 0).unwrap(),
];
let mut idx = 0;
for current_date in cron.iter_from(start_date, Direction::Forward).take(5) {
assert_eq!(expected_dates[idx], current_date);
idx += 1;
}
assert_eq!(idx, 5);
Ok(())
}
#[test]
fn test_cron_expression_second_sunday_using_seven() -> Result<(), CronError> {
use chrono::TimeZone;
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 * * 7#2")?;
let start_date = Local.with_ymd_and_hms(2024, 10, 1, 0, 0, 0).unwrap();
let expected_dates = [
Local.with_ymd_and_hms(2024, 10, 13, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 11, 10, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2024, 12, 8, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2025, 1, 12, 0, 0, 0).unwrap(),
Local.with_ymd_and_hms(2025, 2, 9, 0, 0, 0).unwrap(),
];
let mut idx = 0;
for current_date in cron.iter_from(start_date, Direction::Forward).take(5) {
assert_eq!(expected_dates[idx], current_date);
idx += 1;
}
assert_eq!(idx, 5);
Ok(())
}
#[test]
fn test_specific_and_wildcard_entries() -> Result<(), CronError> {
let cron = Cron::from_str("15 */2 * 3,5 FRI")?;
let matching_time = Local.with_ymd_and_hms(2023, 3, 3, 2, 15, 0).unwrap();
let non_matching_time = Local.with_ymd_and_hms(2023, 3, 3, 3, 15, 0).unwrap();
assert!(cron.is_time_matching(&matching_time)?);
assert!(!cron.is_time_matching(&non_matching_time)?);
Ok(())
}
#[test]
fn test_month_weekday_edge_cases() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * 2-3 SUN")?;
let matching_time = Local.with_ymd_and_hms(2023, 2, 5, 0, 0, 0).unwrap();
let non_matching_time = Local.with_ymd_and_hms(2023, 2, 5, 0, 0, 1).unwrap();
assert!(cron.is_time_matching(&matching_time)?);
assert!(!cron.is_time_matching(&non_matching_time)?);
Ok(())
}
#[test]
fn test_leap_year() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 29 2 *")?;
let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&leap_year_matching)?);
Ok(())
}
#[test]
fn test_tabs_for_separator() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 29 2 *")?;
let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&leap_year_matching)?);
Ok(())
}
#[test]
fn test_mixed_separators() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 29 2 *")?;
let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&leap_year_matching)?);
Ok(())
}
#[test]
fn test_mixed_leading_separators() -> Result<(), CronError> {
let cron = Cron::from_str(" 0 0 29 2 *")?;
let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&leap_year_matching)?);
Ok(())
}
#[test]
fn test_mixed_tailing_separators() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 29 2 * ")?;
let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&leap_year_matching)?);
Ok(())
}
#[test]
fn test_time_overflow() -> Result<(), CronError> {
let cron_match = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("59 59 23 31 12 *")?;
let cron_next = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 1 1 *")?;
let time_matching = Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap();
let next_day = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let next_match = Local.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap();
let is_matching = cron_match.is_time_matching(&time_matching)?;
let next_occurrence = cron_next.find_next_occurrence(&time_matching, false)?;
let next_match_occurrence = cron_match.find_next_occurrence(&time_matching, false)?;
assert!(is_matching);
assert_eq!(next_occurrence, next_day);
assert_eq!(next_match_occurrence, next_match);
Ok(())
}
#[test]
fn test_yearly_recurrence() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 1 1 *")?;
let matching_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let non_matching_time = Local.with_ymd_and_hms(2023, 1, 2, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&matching_time)?);
assert!(!cron.is_time_matching(&non_matching_time)?);
Ok(())
}
fn calculate_hash<T: Hash>(t: &T) -> u64 {
let mut s = DefaultHasher::new();
t.hash(&mut s);
s.finish()
}
#[rstest]
#[case("@hourly", "@daily", false)]
#[case("@daily", "@weekly", false)]
#[case("@weekly", "@monthly", false)]
#[case("@monthly", "@yearly", false)]
#[case("* * * * *", "@hourly", false)]
#[case("@annually", "@yearly", true)]
#[case("* * * * * *", "* * * * *", false)]
#[case("0 12 * * *", "30 0 12 * * *", false)]
#[case("0 0 * * * *", "@hourly", true)]
#[case("5 * * * * *", "10 * * * * *", false)]
#[case("15 * * * *", "45 * * * *", false)]
#[case("* * 8 * *", "* * 18 * *", false)]
#[case("* * * 1 *", "* * * 6 *", false)]
#[case("* * * JAN *", "* * * JUL *", false)]
#[case("* * * * 0", "* * * * 3", false)]
#[case("* * * * SUN", "* * * * WED", false)]
#[case("* * * * 7", "* * * * 1", false)]
#[case("0-29 * * * *", "30-59 * * * *", false)]
#[case("* * 1-11 * *", "* * 12-23 * *", false)]
#[case("* * * JAN-JUN *", "* * * JUL-DEC *", false)]
#[case("* * * * MON-WED", "* * * * THU-SAT", false)]
#[case("* * * * *", "0-5 * * * *", false)]
#[case("*/15 * * * *", "*/30 * * * *", false)]
#[case("0-59/10 * * * *", "5-59/10 * * * *", false)]
#[case("* * 1-10/2 * *", "* * 1-10/3 * *", false)]
#[case("* * * * *", "*/2 * * * *", false)]
#[case("0,10,20 * * * *", "30,40,50 * * * *", false)]
#[case("* * * * MON,WED,FRI", "* * * * TUE,THU,SAT", false)]
#[case("* * * ? * ?", "* * * * * *", true)]
#[case("@monthly", "0 0 1 * *", true)]
#[case("* * * * 1,3,5", "* * * * MON,WED,FRI", true)]
#[case("* * * mar *", "* * * 3 *", true)]
#[case("0 0 * * 1", "0 0 15 * *", false)]
#[case("0 0 1 * *", "0 0 1 * 1", false)]
#[case("* * 1 * *", "* * L * *", false)]
#[case("* * L FEB *", "* * L MAR *", false)]
#[case("* * * * 1#L", "* * * * 2#L", false)]
#[case("* * * * 4#L", "* * * * FRI#L", false)]
#[case("* * 1W * *", "* * 1 * *", false)]
#[case("* * 15W * *", "* * 16W * *", false)]
#[case("* * * * 1#2", "* * * * 1#1", false)]
#[case("* * * * TUE#4", "* * * * TUE#2", false)]
#[case("* * * * 5#1", "* * * * FRI#1", true)]
#[case("* * * * MON#1", "* * * * TUE#1", false)]
#[case("0 10 * * MON#2", "0 10 1-7 * MON", false)]
#[case("*/10 8-10 * JAN,DEC 1-5", "0 12 * * 6", false)]
fn test_comparison_and_hash(
#[case] pattern_1: &str,
#[case] pattern_2: &str,
#[case] equal: bool,
) {
use crate::parser::Seconds;
eprintln!("Parsing {pattern_1}");
let cron_1 = Cron::from_str(pattern_1).unwrap_or_else(|err| {
eprintln!(
"Initial parse attempt failed ({err}). Trying again but with allowed seconds."
);
CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse(pattern_1)
.unwrap()
});
eprintln!("Parsing {pattern_2}");
let cron_2 = Cron::from_str(pattern_2).unwrap_or_else(|err| {
eprintln!(
"Initial parse attempt failed ({err}). Trying again but with allowed seconds."
);
CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse(pattern_2)
.unwrap()
});
assert_eq!(
cron_1 == cron_2,
equal,
"Equality relation between both patterns is not {equal}. {cron_1} != {cron_2}."
);
assert_eq!(
calculate_hash(&cron_1) == calculate_hash(&cron_2),
equal,
"Hashes don't respect quality relation"
);
if !equal {
assert!(
cron_1 > cron_2,
"Ordering between first an second pattern is wrong"
);
}
#[expect(clippy::eq_op, reason = "Want to check Eq is correctly implemented")]
{
assert!(
cron_1 == cron_1,
"Eq implementation is incorrect for first patter"
);
assert!(
cron_2 == cron_2,
"Eq implementation is incorrect for second patter"
);
}
}
#[rstest]
#[case("0 0 1-7 * 1", "0 0 * * 1#1")]
#[case("0 0 8-14 * MON", "0 0 * * MON#2")]
#[should_panic(expected = "Patterns are not equal")]
fn failed_equality(#[case] pattern_1: &str, #[case] pattern_2: &str) {
let cron_1 = Cron::from_str(pattern_1).unwrap();
let cron_2 = Cron::from_str(pattern_2).unwrap();
assert!(cron_1 == cron_2, "Patterns are not equal");
}
#[test]
fn test_find_previous_occurrence() -> Result<(), CronError> {
let cron = Cron::from_str("* * * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 1, 30).unwrap();
let prev_occurrence = cron.find_previous_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 1, 0).unwrap();
assert_eq!(prev_occurrence, expected_time);
Ok(())
}
#[test]
fn test_find_previous_occurrence_inclusive() -> Result<(), CronError> {
let cron = Cron::from_str("* * * * *")?;
let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 1, 0).unwrap();
let prev_occurrence = cron.find_previous_occurrence(&start_time, true)?;
assert_eq!(prev_occurrence, start_time);
Ok(())
}
#[test]
fn test_wrap_year_backwards() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 1 1 *")?; let start_time = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 1).unwrap();
let prev_occurrence = cron.find_previous_occurrence(&start_time, false)?;
let expected_time = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
assert_eq!(prev_occurrence, expected_time);
let start_time_2 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let prev_occurrence_2 = cron.find_previous_occurrence(&start_time_2, false)?;
let expected_time_2 = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
assert_eq!(prev_occurrence_2, expected_time_2);
Ok(())
}
#[test]
fn test_find_occurrence_at_min_year_limit() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 1 1 *")?;
let start_time = Local
.with_ymd_and_hms(YEAR_LOWER_LIMIT, 1, 1, 0, 0, 1)
.unwrap();
let prev_occurrence = cron.find_previous_occurrence(&start_time, false)?;
let expected_time = Local
.with_ymd_and_hms(YEAR_LOWER_LIMIT, 1, 1, 0, 0, 0)
.unwrap();
assert_eq!(prev_occurrence, expected_time);
let result = cron.find_previous_occurrence(&expected_time, false);
assert!(matches!(result, Err(CronError::TimeSearchLimitExceeded)));
Ok(())
}
#[test]
fn test_find_occurrence_at_max_year_limit() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 1 1 *")?;
let start_time = Local
.with_ymd_and_hms(YEAR_UPPER_LIMIT - 1, 12, 31, 23, 59, 59)
.unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = Local
.with_ymd_and_hms(YEAR_UPPER_LIMIT, 1, 1, 0, 0, 0)
.unwrap();
assert_eq!(next_occurrence, expected_time);
let result = cron.find_next_occurrence(&expected_time, false);
assert!(matches!(result, Err(CronError::TimeSearchLimitExceeded)));
Ok(())
}
#[test]
fn test_weekday_for_historical_date_1831() -> Result<(), CronError> {
let cron = Cron::from_str("0 0 * * SUN")?;
let matching_sunday = Local.with_ymd_and_hms(1831, 6, 5, 0, 0, 0).unwrap();
let non_matching_monday = Local.with_ymd_and_hms(1831, 6, 6, 0, 0, 0).unwrap();
assert!(
cron.is_time_matching(&matching_sunday)?,
"Should match on Sunday, June 5, 1831"
);
assert!(
!cron.is_time_matching(&non_matching_monday)?,
"Should not match on Monday, June 6, 1831"
);
Ok(())
}
#[test]
fn test_find_next_occurrence_with_year_range_outside_start() {
let cron = Cron::from_str("0 0 0 1 1 * 2080-2085").unwrap();
let start_time = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false).unwrap();
let expected_time = Local.with_ymd_and_hms(2080, 1, 1, 0, 0, 0).unwrap();
assert_eq!(
next_occurrence, expected_time,
"Iterator should jump forward to the correct year."
);
}
#[test]
fn test_find_previous_occurrence_with_year_range_outside_start() {
let cron = Cron::from_str("0 0 0 1 1 * 2030-2035").unwrap();
let start_time = Local.with_ymd_and_hms(2050, 1, 1, 0, 0, 0).unwrap();
let prev_occurrence = cron.find_previous_occurrence(&start_time, false).unwrap();
let expected_time = Local.with_ymd_and_hms(2035, 1, 1, 0, 0, 0).unwrap();
assert_eq!(
prev_occurrence, expected_time,
"Iteratorn should jump backwards to the correct year."
);
}
#[test]
fn test_dst_gap_fixed_time_job() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("0 30 2 * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 3, 30, 1, 59, 59).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = timezone.with_ymd_and_hms(2025, 3, 30, 3, 0, 0).unwrap();
assert_eq!(
next_occurrence, expected_time,
"Fixed-time job in DST gap should execute on the next valid occurrence of its pattern."
);
Ok(())
}
#[test]
fn test_dst_gap_interval_wildcard_job_minute() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("0 */5 * * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 3, 30, 1, 59, 59).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = timezone.with_ymd_and_hms(2025, 3, 30, 3, 0, 0).unwrap();
assert_eq!(
next_occurrence, expected_time,
"Interval job in DST gap should skip the gap and resume relative to new wall time."
);
Ok(())
}
#[test]
fn test_dst_gap_interval_wildcard_job_second() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("* * * * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 3, 30, 1, 59, 59).unwrap();
let next_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_time = timezone.with_ymd_and_hms(2025, 3, 30, 3, 0, 0).unwrap();
assert_eq!(
next_occurrence, expected_time,
"Every second job in DST gap should jump to the first valid second after the gap."
);
Ok(())
}
#[test]
fn test_dst_overlap_fixed_time_job() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("0 30 2 * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 10, 26, 1, 59, 59).unwrap();
let first_occurrence = cron.find_next_occurrence(&start_time, false)?;
let expected_first_time = timezone
.with_ymd_and_hms(2025, 10, 26, 2, 30, 0)
.earliest()
.unwrap(); assert_eq!(
first_occurrence, expected_first_time,
"Fixed-time job in DST overlap should run at first occurrence."
);
let _next_search_start = timezone
.with_ymd_and_hms(2025, 10, 26, 2, 59, 59)
.earliest()
.unwrap(); let next_search_start_after_overlap =
timezone.with_ymd_and_hms(2025, 10, 26, 3, 0, 0).unwrap();
let next_occurrence_after_overlap =
cron.find_next_occurrence(&next_search_start_after_overlap, false)?;
let expected_next_day = timezone.with_ymd_and_hms(2025, 10, 27, 2, 30, 0).unwrap();
assert_eq!(
next_occurrence_after_overlap, expected_next_day,
"Fixed-time job should not re-run during the repeated hour."
);
Ok(())
}
#[test]
fn test_dst_overlap_interval_wildcard_job() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("0 * * * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 10, 26, 1, 59, 59).unwrap();
let mut occurrences = Vec::new();
let mut iter = cron.iter_after(start_time);
for _ in 0..120 {
if let Some(time) = iter.next() {
occurrences.push(time);
} else {
break;
}
}
assert_eq!(
occurrences.len(),
120,
"Interval job in DST overlap should run for both occurrences of each minute."
);
for m in 0..60 {
let naive_time_m_00 = chrono::NaiveDateTime::new(
chrono::NaiveDate::from_ymd_opt(2025, 10, 26).unwrap(),
chrono::NaiveTime::from_hms_opt(2, m, 0).unwrap(),
);
let ambiguous_m_00 = timezone.from_local_datetime(&naive_time_m_00);
assert_eq!(
occurrences[m as usize],
ambiguous_m_00.earliest().unwrap(),
"Minute {m}: CEST occurrence mismatch"
);
assert_eq!(
occurrences[(60 + m) as usize],
ambiguous_m_00.latest().unwrap(),
"Minute {m}: CET occurrence mismatch"
);
}
assert_moves_one_way(&occurrences, Direction::Forward);
Ok(())
}
fn cest(hour: u32, minute: u32, second: u32) -> chrono::DateTime<Tz> {
chrono_tz::Europe::Paris
.with_ymd_and_hms(2024, 10, 27, hour, minute, second)
.earliest()
.expect("the test time must exist")
}
fn cet(hour: u32, minute: u32, second: u32) -> chrono::DateTime<Tz> {
chrono_tz::Europe::Paris
.with_ymd_and_hms(2024, 10, 27, hour, minute, second)
.latest()
.expect("the test time must exist")
}
#[track_caller]
fn assert_moves_one_way(times: &[chrono::DateTime<Tz>], direction: Direction) {
for pair in times.windows(2) {
let moved_the_right_way = match direction {
Direction::Forward => pair[0] < pair[1],
Direction::Backward => pair[0] > pair[1],
};
assert!(
moved_the_right_way,
"a {direction:?} search went the other way, from {} to {}",
pair[0], pair[1]
);
}
}
#[test]
fn dst_overlap_search_starts_from_the_half_it_is_given() -> Result<(), CronError> {
let cron = Cron::from_str("* * * * *")?;
assert_eq!(
cron.find_next_occurrence(&cet(2, 30, 0), false)?,
cet(2, 31, 0)
);
assert_eq!(
cron.find_next_occurrence(&cest(2, 30, 0), false)?,
cest(2, 31, 0)
);
assert_eq!(
cron.find_previous_occurrence(&cet(2, 30, 0), false)?,
cet(2, 29, 0)
);
assert_eq!(
cron.find_previous_occurrence(&cest(2, 30, 0), false)?,
cest(2, 29, 0)
);
assert_eq!(
cron.find_next_occurrence(&cest(2, 59, 0), false)?,
cet(2, 0, 0)
);
assert_eq!(
cron.find_previous_occurrence(&cet(2, 0, 0), false)?,
cest(2, 59, 0)
);
assert_eq!(
cron.find_next_occurrence(&cest(1, 59, 0), false)?,
cest(2, 0, 0)
);
assert_eq!(
cron.find_previous_occurrence(&cet(3, 0, 0), false)?,
cet(2, 59, 0)
);
Ok(())
}
#[test]
fn dst_overlap_iterators_run_through_both_halves_in_order() -> Result<(), CronError> {
let cron = Cron::from_str("* * * * *")?;
let forward: Vec<_> = cron.iter_after(cest(1, 59, 0)).take(121).collect();
assert_moves_one_way(&forward, Direction::Forward);
assert_eq!(forward[0], cest(2, 0, 0));
assert_eq!(forward[59], cest(2, 59, 0));
assert_eq!(forward[60], cet(2, 0, 0));
assert_eq!(forward[119], cet(2, 59, 0));
assert_eq!(forward[120], cet(3, 0, 0));
let mut backward: Vec<_> = cron.iter_before(cet(3, 0, 0)).take(121).collect();
assert_moves_one_way(&backward, Direction::Backward);
assert_eq!(backward[0], cet(2, 59, 0));
assert_eq!(backward[59], cet(2, 0, 0));
assert_eq!(backward[60], cest(2, 59, 0));
assert_eq!(backward[119], cest(2, 0, 0));
assert_eq!(backward[120], cest(1, 59, 0));
backward.reverse();
assert_eq!(&backward[1..], &forward[..120]);
Ok(())
}
#[test]
fn dst_overlap_starting_mid_range_stays_on_its_own_half() -> Result<(), CronError> {
let cron = Cron::from_str("*/15 * * * *")?;
let forward: Vec<_> = cron.iter_after(cest(2, 45, 0)).take(5).collect();
assert_moves_one_way(&forward, Direction::Forward);
assert_eq!(
forward,
vec![
cet(2, 0, 0),
cet(2, 15, 0),
cet(2, 30, 0),
cet(2, 45, 0),
cet(3, 0, 0)
]
);
let backward: Vec<_> = cron.iter_before(cet(2, 0, 0)).take(4).collect();
assert_moves_one_way(&backward, Direction::Backward);
assert_eq!(
backward,
vec![
cest(2, 45, 0),
cest(2, 30, 0),
cest(2, 15, 0),
cest(2, 0, 0)
]
);
Ok(())
}
#[test]
fn dst_overlap_fixed_time_job_runs_once_from_either_half() -> Result<(), CronError> {
let cron = Cron::from_str("30 2 * * *")?;
assert_eq!(
cron.find_next_occurrence(&cest(1, 0, 0), false)?,
cest(2, 30, 0)
);
let next_day = chrono_tz::Europe::Paris
.with_ymd_and_hms(2024, 10, 28, 2, 30, 0)
.unwrap();
assert_eq!(cron.find_next_occurrence(&cest(2, 30, 0), false)?, next_day);
assert_eq!(cron.find_next_occurrence(&cet(2, 45, 0), false)?, next_day);
assert_eq!(
cron.find_previous_occurrence(&cet(4, 0, 0), false)?,
cest(2, 30, 0)
);
assert_eq!(cron.find_next_occurrence(&cet(2, 0, 0), false)?, next_day);
assert_eq!(
cron.find_previous_occurrence(&cet(2, 15, 0), false)?,
cest(2, 30, 0)
);
Ok(())
}
#[test]
fn dst_overlap_fixed_time_match_in_a_later_overlap_still_counts() -> Result<(), CronError> {
let cron = Cron::from_str("30 2 26 10 *")?;
let expected = chrono_tz::Europe::Paris
.with_ymd_and_hms(2025, 10, 26, 2, 30, 0)
.earliest()
.expect("the test time must exist");
assert_eq!(cron.find_next_occurrence(&cet(2, 45, 0), false)?, expected);
Ok(())
}
#[test]
fn dst_overlap_crossing_ignores_a_match_in_a_later_overlap() -> Result<(), CronError> {
let cron = Cron::from_str("*/20 2 26-27 10 *")?;
assert_eq!(
cron.find_next_occurrence(&cest(2, 41, 0), false)?,
cet(2, 0, 0)
);
Ok(())
}
#[test]
fn iteration_steps_over_no_matching_time() -> Result<(), CronError> {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* * * * * *")?;
let start = chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
let seconds: Vec<_> = cron
.iter_after(start)
.take(5)
.map(|time| time.second())
.collect();
assert_eq!(seconds, vec![1, 2, 3, 4, 5]);
let from_start: Vec<_> = cron
.iter_from(start, Direction::Forward)
.take(3)
.map(|time| time.second())
.collect();
assert_eq!(from_start, vec![0, 1, 2]);
Ok(())
}
#[test]
fn test_dst_overlap_interval_wildcard_job_hour_step() -> Result<(), CronError> {
let timezone: Tz = "Europe/Stockholm".parse().unwrap();
let cron = Cron::from_str("0 0 */2 * * *")?; let start_time = timezone.with_ymd_and_hms(2025, 10, 26, 0, 0, 0).unwrap();
let mut iter = cron.iter_from(start_time, Direction::Forward);
let first_run = iter.next().unwrap(); let second_run = iter.next().unwrap(); let third_run = iter.next().unwrap(); let fourth_run = iter.next().unwrap();
let naive_time_2_00 = chrono::NaiveDateTime::new(
chrono::NaiveDate::from_ymd_opt(2025, 10, 26).unwrap(),
chrono::NaiveTime::from_hms_opt(2, 0, 0).unwrap(),
);
let ambiguous_2_00 = timezone.from_local_datetime(&naive_time_2_00);
assert_eq!(
first_run,
timezone.with_ymd_and_hms(2025, 10, 26, 0, 0, 0).unwrap()
);
assert_eq!(second_run, ambiguous_2_00.earliest().unwrap()); assert_eq!(third_run, ambiguous_2_00.latest().unwrap()); assert_eq!(
fourth_run,
timezone.with_ymd_and_hms(2025, 10, 26, 4, 0, 0).unwrap()
);
Ok(())
}
#[test]
fn weekday_stays_in_step_over_a_long_search() -> Result<(), CronError> {
let start = chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
for (pattern, expected) in [
("0 0 * * FRI", chrono::Weekday::Fri),
("0 0 * * SUN", chrono::Weekday::Sun),
] {
let cron = Cron::from_str(pattern)?;
let forwards = cron.iter_after(start).take(300);
let backwards = cron.iter_before(start).take(300);
for occurrence in forwards.chain(backwards) {
assert_eq!(
occurrence.weekday(),
expected,
"{pattern} matched {occurrence}"
);
}
}
Ok(())
}
#[test]
fn nth_and_last_weekday_hold_across_months() -> Result<(), CronError> {
let start = chrono::Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
for occurrence in Cron::from_str("0 0 * * WED#3")?.iter_after(start).take(60) {
assert_eq!(occurrence.weekday(), chrono::Weekday::Wed);
assert!(
(15..=21).contains(&occurrence.day()),
"the third Wednesday falls between the 15th and the 21st, got {occurrence}"
);
}
for occurrence in Cron::from_str("0 0 * * MONL")?.iter_after(start).take(60) {
assert_eq!(occurrence.weekday(), chrono::Weekday::Mon);
let a_week_later = occurrence + chrono::Duration::days(7);
assert_ne!(
a_week_later.month(),
occurrence.month(),
"the last Monday has no Monday after it in the same month, got {occurrence}"
);
}
Ok(())
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use std::str::FromStr as _;
use serde_test::{assert_de_tokens_error, assert_tokens, Token};
use crate::Cron;
#[test]
fn test_serde_tokens() {
let cron = Cron::from_str("0 0 * * *").expect("should be valid pattern");
assert_tokens(&cron.to_string(), &[Token::Str("0 0 * * *")]);
}
#[test]
fn test_shorthand_serde_tokens() {
let expressions = [
("@daily", "0 0 * * *"),
("0 12 * * MON", "0 12 * * 1"),
("*/15 9-17 * * MON-FRI", "*/15 9-17 * * 1-5"),
];
for (shorthand, expected) in expressions.iter() {
let cron = Cron::from_str(shorthand).expect("should be valid pattern");
assert_tokens(&cron.to_string(), &[Token::Str(expected)]);
}
}
#[test]
fn test_invalid_serde_tokens() {
assert_de_tokens_error::<Cron>(
&[Token::Str("Invalid cron pattern")],
"Invalid pattern: Pattern must have between 5 and 7 fields.",
);
}
}