Files
Notes/notes-service/vendor/croner/src/pattern.rs
T
2026-08-01 16:11:49 +03:00

547 lines
20 KiB
Rust

use std::cmp::Ordering;
use std::hash::Hasher;
use crate::component::{
CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT,
NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL,
};
use crate::errors::CronError;
use crate::{Direction, TimeComponent, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT};
use chrono::{Datelike, Duration, NaiveDate, Weekday};
// This struct is used for representing and validating cron pattern strings.
#[derive(Debug, Clone, Eq)]
pub struct CronPattern {
pub(crate) pattern: String, // The original pattern
pub seconds: CronComponent, // -
pub minutes: CronComponent, // --
pub hours: CronComponent, // --- Each individual part of the cron expression
pub days: CronComponent, // --- represented by a bitmask, min and max value
pub months: CronComponent, // ---
pub days_of_week: CronComponent, // --
pub years: CronComponent, // -
pub(crate) star_dom: bool,
pub(crate) star_dow: bool,
pub(crate) dom_and_dow: bool,
}
// Implementation block for CronPattern struct
impl CronPattern {
pub fn new(pattern: &str) -> Self {
Self {
pattern: pattern.to_string(),
seconds: CronComponent::new(0, 59, NONE_BIT, 0),
minutes: CronComponent::new(0, 59, NONE_BIT, 0),
hours: CronComponent::new(0, 23, NONE_BIT, 0),
days: CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0),
months: CronComponent::new(1, 12, NONE_BIT, 0),
days_of_week: CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0),
years: CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0), // Use u16 for year range
star_dom: false,
star_dow: false,
dom_and_dow: false,
}
}
// Checks if a given year matches the year part of the cron pattern.
pub fn year_match(&self, year: i32) -> Result<bool, CronError> {
if !(YEAR_LOWER_LIMIT..=YEAR_UPPER_LIMIT).contains(&year) {
// This case should ideally be prevented by search limits, but serves as a safeguard.
return Ok(false);
}
self.years.is_bit_set(year as u16, ALL_BIT) // Use u16 cast
}
// Determines the nth weekday of the month
fn is_nth_weekday_of_month(date: chrono::NaiveDate, nth: u8, weekday: Weekday) -> bool {
let mut count = 0;
let mut current = date.with_day(1).unwrap();
while current.month() == date.month() {
if current.weekday() == weekday {
count += 1;
if count == nth {
return current.day() == date.day();
}
}
current += chrono::Duration::days(1);
}
false
}
// Checks if a given year, month, and day match the day part of the cron pattern.
pub fn day_match(&self, year: i32, month: u32, day: u32) -> Result<bool, CronError> {
if day == 0 || day > 31 || month == 0 || month > 12 {
return Err(CronError::InvalidDate);
}
let date = NaiveDate::from_ymd_opt(year, month, day).ok_or(CronError::InvalidDate)?;
let mut day_matches = self.days.is_bit_set(day as u16, ALL_BIT)?; // Use u16
let mut dow_matches = false;
if !day_matches
&& self.days.is_feature_enabled(LAST_BIT)
&& day == Self::last_day_of_month(year, month)?
{
day_matches = true;
}
if !day_matches && self.closest_weekday(year, month, day)? {
day_matches = true;
}
for nth in 1..=5 {
let nth_bit = match nth {
1 => NTH_1ST_BIT,
2 => NTH_2ND_BIT,
3 => NTH_3RD_BIT,
4 => NTH_4TH_BIT,
5 => NTH_5TH_BIT,
_ => continue,
};
if self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, nth_bit)? // Use u16
&& Self::is_nth_weekday_of_month(date, nth, date.weekday())
{
dow_matches = true;
break;
}
}
if !dow_matches
&& self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, LAST_BIT)? // Use u16
&& (date + chrono::Duration::days(7)).month() != date.month()
{
dow_matches = true;
}
dow_matches = dow_matches
|| self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, ALL_BIT)?; // Use u16
if (day_matches && self.star_dow) || (dow_matches && self.star_dom) {
Ok(true)
} else if !self.star_dom && !self.star_dow {
if !self.dom_and_dow {
Ok(day_matches || dow_matches)
} else {
Ok(day_matches && dow_matches)
}
} else {
Ok(false)
}
}
// Helper function to find the last day of a given month
fn last_day_of_month(year: i32, month: u32) -> Result<u32, CronError> {
if !(1..=12).contains(&month) {
return Err(CronError::InvalidDate);
}
let (y, m) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
Ok(NaiveDate::from_ymd_opt(y, m, 1)
.unwrap()
.pred_opt()
.unwrap()
.day())
}
pub fn closest_weekday(&self, year: i32, month: u32, day: u32) -> Result<bool, CronError> {
// Iterate through all possible days to see if any have the 'W' flag.
for pattern_day_u16 in 1..=31 {
if self.days.is_bit_set(pattern_day_u16, CLOSEST_WEEKDAY_BIT)? {
// A 'W' day exists in the pattern. Check if it resolves to the function's date argument.
let pattern_day = pattern_day_u16 as u32;
// Ensure the 'W' day is a valid calendar date for the given month/year.
if let Some(pattern_date) = NaiveDate::from_ymd_opt(year, month, pattern_day) {
let weekday = pattern_date.weekday();
// Determine the actual trigger date based on the 'W' rule.
let target_date = match weekday {
// If the pattern day is a weekday, it triggers on that day.
Weekday::Mon
| Weekday::Tue
| Weekday::Wed
| Weekday::Thu
| Weekday::Fri => pattern_date,
// If it's a Saturday, find the nearest weekday within the month.
Weekday::Sat => {
// The nearest weekday is Friday, but check if it's in the same month.
let adjusted_date = pattern_date - Duration::days(1);
if adjusted_date.month() == month {
adjusted_date // It's Friday of the same month.
} else {
// Crossed boundary (e.g., 1st was Sat), so move forward to Monday.
pattern_date + Duration::days(2)
}
}
// If it's a Sunday, find the nearest weekday within the month.
Weekday::Sun => {
// The nearest weekday is Monday, but check if it's in the same month.
let adjusted_date = pattern_date + Duration::days(1);
if adjusted_date.month() == month {
adjusted_date // It's Monday of the same month.
} else {
// Crossed boundary (e.g., 31st was Sun), so move back to Friday.
pattern_date - Duration::days(2)
}
}
};
// Check if the calculated target day is the day we're currently testing.
if target_date.day() == day && target_date.month() == month {
return Ok(true);
}
}
}
}
// No 'W' pattern matched the current day.
Ok(false)
}
pub fn month_match(&self, month: u32) -> Result<bool, CronError> {
if !(1..=12).contains(&month) {
return Err(CronError::InvalidDate);
}
self.months.is_bit_set(month as u16, ALL_BIT)
}
pub fn hour_match(&self, hour: u32) -> Result<bool, CronError> {
if hour > 23 {
return Err(CronError::InvalidTime);
}
self.hours.is_bit_set(hour as u16, ALL_BIT)
}
pub fn minute_match(&self, minute: u32) -> Result<bool, CronError> {
if minute > 59 {
return Err(CronError::InvalidTime);
}
self.minutes.is_bit_set(minute as u16, ALL_BIT)
}
pub fn second_match(&self, second: u32) -> Result<bool, CronError> {
if second > 59 {
return Err(CronError::InvalidTime);
}
self.seconds.is_bit_set(second as u16, ALL_BIT)
}
/// Finds the next or previous matching value for a given time component based on direction.
pub fn find_match_in_component(
&self,
value: u32,
component_type: TimeComponent,
direction: Direction,
) -> Result<Option<u32>, CronError> {
let component = match component_type {
TimeComponent::Second => &self.seconds,
TimeComponent::Minute => &self.minutes,
TimeComponent::Hour => &self.hours,
_ => {
return Err(CronError::ComponentError(
"Invalid component type for match search".to_string(),
))
}
};
let value_u16 = value as u16;
if value_u16 > component.max {
return Err(CronError::ComponentError(format!(
"Input value {} is out of bounds for the component (max: {}).",
value, component.max
)));
}
match direction {
Direction::Forward => {
for next_value in value_u16..=component.max {
if component.is_bit_set(next_value, ALL_BIT)? {
return Ok(Some(next_value as u32));
}
}
}
Direction::Backward => {
for prev_value in (component.min..=value_u16).rev() {
if component.is_bit_set(prev_value, ALL_BIT)? {
return Ok(Some(prev_value as u32));
}
}
}
}
Ok(None)
}
/// Returns a human-readable description of the cron pattern.
///
/// This method provides a best-effort English description of the cron schedule.
/// Note: The pattern must be parsed successfully before calling this method.
/// Returns a human-readable description of the cron pattern in English.
pub fn describe(&self) -> String {
self.describe_lang(crate::describe::English)
}
/// Returns a human-readable description using a provided language provider.
///
/// # Arguments
///
/// * `lang` - An object that implements the `Language` trait.
pub fn describe_lang<L: crate::describe::Language>(&self, lang: L) -> String {
crate::describe::describe(self, &lang)
}
// Get a reference to the original pattern
pub fn as_str(&self) -> &str {
&self.pattern
}
}
impl std::fmt::Display for CronPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.pattern)
}
}
impl PartialEq for CronPattern {
/// Checks for functional equality between two CronPattern instances.
///
/// Two patterns are considered equal if they have been parsed and their
/// resulting schedule components and behavioral options are identical.
/// The original pattern string is ignored in this comparison.
///
/// Returns `false` if either pattern has not been parsed.
fn eq(&self, other: &Self) -> bool {
// Compare all components and boolean flags that define the schedule.
self.seconds == other.seconds
&& self.minutes == other.minutes
&& self.hours == other.hours
&& self.days == other.days
&& self.months == other.months
&& self.days_of_week == other.days_of_week
&& self.years == other.years
&& self.star_dom == other.star_dom
&& self.star_dow == other.star_dow
&& self.dom_and_dow == other.dom_and_dow
}
}
// To implement Ord, we must first implement PartialOrd.
// For types where comparison never fails, this is the standard way to do it.
impl PartialOrd for CronPattern {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
// The primary implementation for Ord.
impl Ord for CronPattern {
/// Implements the total ordering for `CronPattern`.
///
/// This allows for consistent, deterministic sorting of cron patterns based on
/// their functional schedule, not their string representation. The comparison
/// is performed lexicographically on the parsed time components and behavioral flags.
///
/// An unparsed pattern is always considered less than a parsed one.
fn cmp(&self, other: &Self) -> Ordering {
// Compare the time components in logical order, from most to least
// significant.
self.seconds
.cmp(&other.seconds)
.then_with(|| self.minutes.cmp(&other.minutes))
.then_with(|| self.hours.cmp(&other.hours))
.then_with(|| self.days.cmp(&other.days))
.then_with(|| self.months.cmp(&other.months))
.then_with(|| self.days_of_week.cmp(&other.days_of_week))
.then_with(|| self.years.cmp(&other.years))
// Finally, compare the boolean flags to ensure a stable order
// for patterns that are otherwise identical.
.then_with(|| self.star_dom.cmp(&other.star_dom))
.then_with(|| self.star_dow.cmp(&other.star_dow))
.then_with(|| self.dom_and_dow.cmp(&other.dom_and_dow))
}
}
impl std::hash::Hash for CronPattern {
/// Hashes the functionally significant fields of the CronPattern.
///
/// This implementation is consistent with the `PartialEq` implementation,
/// ensuring that functionally identical patterns produce the same hash.
/// The original pattern string is not included in the hash.
fn hash<H: Hasher>(&self, state: &mut H) {
self.seconds.hash(state);
self.minutes.hash(state);
self.hours.hash(state);
self.days.hash(state);
self.months.hash(state);
self.days_of_week.hash(state);
self.years.hash(state);
self.star_dom.hash(state);
self.star_dow.hash(state);
self.dom_and_dow.hash(state);
}
}
#[cfg(test)]
mod tests {
use crate::parser::{CronParser, Seconds};
use super::*;
#[test]
fn test_last_day_of_month() -> Result<(), CronError> {
// Check the last day of February for a non-leap year
assert_eq!(CronPattern::last_day_of_month(2021, 2)?, 28);
// Check the last day of February for a leap year
assert_eq!(CronPattern::last_day_of_month(2020, 2)?, 29);
// Check for an invalid month (0 or greater than 12)
assert!(CronPattern::last_day_of_month(2023, 0).is_err());
assert!(CronPattern::last_day_of_month(2023, 13).is_err());
Ok(())
}
#[test]
fn test_closest_weekday() -> Result<(), CronError> {
// Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 15W * *")?;
// Test a month where the 15th is a weekday
// Assuming 15th is Wednesday (a weekday), the closest weekday is the same day.
let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Saturday
// The closest weekday would be Friday, 14th.
let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Sunday
// The closest weekday would be Monday, 16th.
let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a non-matching date
let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023
assert!(!cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
Ok(())
}
#[test]
fn test_closest_weekday_with_alternative_weekdays() -> Result<(), CronError> {
// Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month
let cron = CronParser::builder()
.seconds(Seconds::Required)
.alternative_weekdays(true)
.build()
.parse("0 0 0 15W * *")?;
// Test a month where the 15th is a weekday
// Assuming 15th is Wednesday (a weekday), the closest weekday is the same day.
let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Saturday
// The closest weekday would be Friday, 14th.
let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Sunday
// The closest weekday would be Monday, 16th.
let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a non-matching date
let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023
assert!(!cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
Ok(())
}
#[test]
fn test_closest_weekday_month_boundary() -> Result<(), CronError> {
// --- TEST START OF MONTH ---
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 1W * *")?;
// Case 1: The 1st is a Saturday (Nov 2025).
// Should trigger on Monday the 3rd, not jump back to October.
assert!(
!cron.pattern.day_match(2025, 10, 31)?,
"Should not trigger on previous month"
);
assert!(
cron.pattern.day_match(2025, 11, 3)?,
"Should trigger on Mon 3rd for Sat 1st"
);
assert!(
!cron.pattern.day_match(2025, 11, 1)?,
"Should not trigger on Sat 1st itself"
);
// Case 2: The 1st is a Sunday (June 2025).
// Should trigger on Monday the 2nd.
assert!(
cron.pattern.day_match(2025, 6, 2)?,
"Should trigger on Mon 2nd for Sun 1st"
);
assert!(
!cron.pattern.day_match(2025, 6, 3)?,
"Should NOT trigger on Tue 3rd for Sun 1st"
);
// --- TEST END OF MONTH ---
let cron_end = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 31W * *")?;
// Case 3: The 31st is a Sunday (Aug 2025).
// Should trigger on Friday the 29th, not jump forward to September.
assert!(
cron_end.pattern.day_match(2025, 8, 29)?,
"Should trigger on Fri 29th for Sun 31st"
);
assert!(
!cron_end.pattern.day_match(2025, 9, 1)?,
"Should not trigger on next month"
);
Ok(())
}
}