Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
+590
View File
@@ -0,0 +1,590 @@
use crate::errors::CronError;
// Constants for flags
pub const NONE_BIT: u8 = 0;
pub const ALL_BIT: u8 = 1;
// Used for nth weekday
pub const NTH_1ST_BIT: u8 = 1 << 1;
pub const NTH_2ND_BIT: u8 = 1 << 2;
pub const NTH_3RD_BIT: u8 = 1 << 3;
pub const NTH_4TH_BIT: u8 = 1 << 4;
pub const NTH_5TH_BIT: u8 = 1 << 5;
pub const NTH_ALL: u8 = NTH_1ST_BIT | NTH_2ND_BIT | NTH_3RD_BIT | NTH_4TH_BIT | NTH_5TH_BIT;
// Used for closest weekday
pub const CLOSEST_WEEKDAY_BIT: u8 = 1 << 7;
// Used for last day of month
pub const LAST_BIT: u8 = 1 << 6;
/// Represents a component of a cron pattern, such as minute, hour, or day of week.
///
/// Each `CronComponent` holds information about permissible values (min, max),
/// features supported (like last day of the month), and specific bits set
/// for scheduling purposes.
///
/// # Examples (for internal use only, CronComponent isn't exported)
///
/// let mut minute_component = CronComponent::new(0, 59, CronComponent::LAST_BIT);
/// // Represents a minute component that supports the 'last' feature.
///
/// // Parsing a cron expression for minute component
/// // This sets specific bits in the component according to the cron syntax
/// minute_component.parse("*/15").expect("Parsing failed");
/// // Sets the minute component to trigger at every 15th minute
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CronComponent {
bitfields: Vec<u8>, // Vector of u8 to act as multiple bitfields
pub min: u16, // Minimum value this component can take
pub max: u16, // Maximum value this component can take
pub step: u16, // Steps to skip in this component
pub from_wildcard: bool, // Wildcard used
features: u8, // Single u8 bitfield to indicate supported special bits, like LAST_BIT
enabled_features: u8, // Bitfield to hold component-wide special bits like LAST_BIT
input_offset: u16, // Offset for numerical representation
}
impl CronComponent {
/// Creates a new `CronComponent` with specified minimum and maximum values and features.
///
/// `min` and `max` define the range of values this component can take.
/// `features` is a bitfield specifying supported special features.
///
/// # Parameters
///
/// - `min`: The minimum permissible value for this component.
/// - `max`: The maximum permissible value for this component.
/// - `features`: Bitfield indicating special features like `LAST_BIT`.
///
/// # Returns
///
/// Returns a new instance of `CronComponent`.
pub fn new(min: u16, max: u16, features: u8, input_offset: u16) -> Self {
// Handle the case where max might make usize overflow if not checked
let bitfields_size = if max > 0 { max as usize + 1 } else { 0 };
Self {
// Vector of u8 to act as multiple bitfields.
// - Initialized with NONE_BIT for each element.
bitfields: vec![NONE_BIT; bitfields_size],
// Minimum value this component can take.
// - Example: 0 for the minute-field
min,
// Maximum value this component can take.
// - Example: 59 for the minute-field
max,
// Bitfield to indicate _supported_ special bits, like LAST_BIT.
// - ALL_BIT and LAST_BIT is always allowed
features: features | ALL_BIT | LAST_BIT,
// Bitfield to indicate _enabled_ component-wide special bits like LAST_BIT.
// - No features are enabled by default
enabled_features: 0,
// Offset for numerical representation of weekdays. normally 0=SUN,1=MON etc, setting this to 1 makes 1=SUN...
input_offset,
step: 1, // Used by .describe()
from_wildcard: false, // Used by .describe()
}
}
// Method primarily used by .describe() to evaluate if all bits are set
pub fn is_all_set(&self) -> bool {
// A component is "all set" if it's a '*' with no step.
// We check if all bits in its range are set for the ALL_BIT flag.
for i in self.min..=self.max {
if !self.is_bit_set(i, ALL_BIT).unwrap_or(false) {
return false;
}
}
true
}
// Set a bit at a given position (e.g., 0 to 9999 for year)
pub fn set_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> {
if pos < self.input_offset {
return Err(CronError::ComponentError(format!(
"Position {} is less than the input offset {}.",
pos, self.input_offset
)));
}
pos -= self.input_offset;
if pos < self.min || pos > self.max {
return Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)));
}
if self.features & bit != bit {
return Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)));
}
let index = pos as usize; // Convert the position to an index
if index >= self.bitfields.len() {
// In case the index is somehow out of the vector's bounds
return Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)));
}
self.bitfields[index] |= bit; // Set the specific bit at the position
Ok(())
}
// Unset a specific bit at a given position
pub fn unset_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> {
if pos < self.input_offset {
return Err(CronError::ComponentError(format!(
"Position {} is less than the input offset {}.",
pos, self.input_offset
)));
}
pos -= self.input_offset;
if pos < self.min || pos > self.max {
return Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)));
}
if self.features & bit != bit {
return Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)));
}
let index = pos as usize; // Convert the position to an index
if index >= self.bitfields.len() {
// In case the index is somehow out of the vector's bounds
return Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)));
}
self.bitfields[index] &= !bit; // Unset the specific bit at the position
Ok(())
}
// Check if a specific bit at a given position is set
pub fn is_bit_set(&self, pos: u16, bit: u8) -> Result<bool, CronError> {
if pos < self.min || pos > self.max {
Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)))
} else if self.features & bit != bit {
Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)))
} else {
let index = pos as usize;
if index >= self.bitfields.len() {
Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)))
} else {
Ok((self.bitfields[index] & bit) != 0)
}
}
}
// Method to enable a feature
pub fn enable_feature(&mut self, feature: u8) -> Result<(), CronError> {
if self.is_feature_allowed(feature) {
self.enabled_features |= feature;
Ok(())
} else {
Err(CronError::ComponentError(format!(
"Feature 0b{:08b} is not supported by the current features 0b{:08b}.",
feature, self.features
)))
}
}
pub fn is_feature_allowed(&mut self, feature: u8) -> bool {
self.features & feature == feature
}
// Method to check if a feature is enabled
pub fn is_feature_enabled(&self, feature: u8) -> bool {
(self.enabled_features & feature) == feature
}
/// Parses a part of a cron expression string and sets the corresponding bits in the component.
///
/// This method interprets the cron syntax provided in `field` and sets
/// the relevant bits in the component. It supports standard cron patterns
/// like '*', '-', '/', and 'w'. For example, '*/15' in a minute component
/// would set the bits for every 15th minute.
///
/// # Parameters
///
/// - `field`: A string slice containing the cron expression part to parse.
///
/// # Returns
///
/// Returns `Ok(())` if parsing is successful, or `CronError` if the parsing fails.
///
/// # Errors
///
/// Returns `CronError::ComponentError` if the input string contains invalid
/// cron syntax or values outside the permissible range of the component.
///
/// # Examples (for internal use only, CronComponent isn't exported)
///
/// use crate::component::CronComponent;
/// let mut hour_component = CronComponent::new(0, 23, 0);
/// hour_component.parse("*/3").expect("Parsing failed");
/// // Sets the hour component to trigger at every 3rd hour
pub fn parse(&mut self, field: &str) -> Result<(), CronError> {
if field == "*" {
self.from_wildcard = true;
for value in self.min..=self.max {
self.set_bit(value + self.input_offset, ALL_BIT)?;
}
return Ok(());
}
for part in field.split(',') {
let trimmed_part = part.trim();
if trimmed_part.is_empty() {
continue;
}
let mut parsed_part = trimmed_part.to_string();
if parsed_part.contains('/') {
self.handle_stepping(&parsed_part)?;
} else if parsed_part.contains('-') {
self.handle_range(&parsed_part)?;
} else if parsed_part.contains('W') {
self.handle_closest_weekday(&parsed_part)?;
} else if parsed_part.eq_ignore_ascii_case("L") {
// Handle "L" for the last bit
self.enable_feature(LAST_BIT)?;
} else {
// If 'L' is contained without '#', like "5L", add the missing '#'
if parsed_part.ends_with('L') && !parsed_part.contains('#') {
parsed_part = parsed_part.replace('L', "#L");
}
// If '#' is contained in the number, require feature NTH_ALL to be set
if parsed_part.contains('#') && !self.is_feature_allowed(NTH_ALL) {
return Err(CronError::ComponentError(
"Nth specifier # not allowed in the current field.".to_string(),
));
}
// If 'L' is contained in the number, require feature NTH_ALL to be set
if parsed_part.contains('L') && !self.is_feature_allowed(NTH_ALL) {
return Err(CronError::ComponentError(
"L not allowed in the current field.".to_string(),
));
}
self.handle_number(&parsed_part)?;
}
}
Ok(())
}
/// Returns a vector of u16 values for all bits set in the component for a given bitflag.
pub fn get_set_values(&self, bit: u8) -> Vec<u16> {
(self.min..=self.max)
.filter(|i| self.is_bit_set(*i, bit).unwrap_or(false))
.collect()
}
fn get_nth_bit(value: &str) -> Result<u8, CronError> {
// If value ends with 'L', we set the LAST_BIT and exit early
if value.ends_with('L') {
return Ok(LAST_BIT);
}
if let Some(nth_pos) = value.find('#') {
let nth = value[nth_pos + 1..]
.parse::<u8>()
.map_err(|_| CronError::ComponentError("Invalid nth specifier.".to_string()))?;
if nth == 0 || nth > 5 {
Err(CronError::ComponentError(
"Nth specifier out of bounds.".to_string(),
))
} else {
match nth {
1 => Ok(NTH_1ST_BIT),
2 => Ok(NTH_2ND_BIT),
3 => Ok(NTH_3RD_BIT),
4 => Ok(NTH_4TH_BIT),
5 => Ok(NTH_5TH_BIT),
_ => Err(CronError::ComponentError(
"Invalid nth specifier.".to_string(),
)),
}
}
} else {
Ok(ALL_BIT)
}
}
// Removes everything after #
fn strip_nth_part(value: &str) -> &str {
value.split('#').next().unwrap_or("")
}
fn handle_closest_weekday(&mut self, value: &str) -> Result<(), CronError> {
if let Some(day_pos) = value.find('W') {
// Use a slice
let day_str = &value[..day_pos];
// Parse the day from the slice
let day = day_str.parse::<u16>().map_err(|_| {
CronError::ComponentError("Invalid day for closest weekday.".to_string())
})?;
// Check if the day is within the allowed range
if day < self.min || day > self.max {
return Err(CronError::ComponentError(
"Day for closest weekday out of bounds.".to_string(),
));
}
// Set the bit for the closest weekday
self.set_bit(day, CLOSEST_WEEKDAY_BIT)?;
} else {
// If 'W' is not found, handle the value as a regular number
self.handle_number(value)?;
}
Ok(())
}
fn handle_range(&mut self, range: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(range)?;
let str_clean = CronComponent::strip_nth_part(range);
let parts: Vec<&str> = str_clean.split('-').map(str::trim).collect();
if parts.len() != 2 {
return Err(CronError::ComponentError(
"Invalid range syntax.".to_string(),
));
}
let start = parts[0]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid start of range.".to_string()))?;
let end = parts[1]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid end of range.".to_string()))?;
if start > end || start < self.min || end > self.max {
return Err(CronError::ComponentError(
"Range out of bounds.".to_string(),
));
}
for value in start..=end {
self.set_bit(value, bit_to_set)?;
}
Ok(())
}
fn handle_number(&mut self, value: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(value)?;
let value_clean = CronComponent::strip_nth_part(value);
let num = value_clean
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid number.".to_string()))?;
if num < self.min || num > self.max {
return Err(CronError::ComponentError(
"Number out of bounds.".to_string(),
));
}
self.set_bit(num, bit_to_set)?;
Ok(())
}
pub fn handle_stepping(&mut self, stepped_range: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(stepped_range)?;
let stepped_range_clean = CronComponent::strip_nth_part(stepped_range);
let parts: Vec<&str> = stepped_range_clean.split('/').collect();
if parts.len() != 2 {
return Err(CronError::ComponentError(
"Invalid stepped range syntax.".to_string(),
));
}
let range_part = parts[0];
let step_str = parts[1];
let step = step_str
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid step.".to_string()))?;
self.step = step;
if step == 0 {
return Err(CronError::ComponentError(
"Step cannot be zero.".to_string(),
));
}
let (start, end) = if range_part == "*" {
self.from_wildcard = true;
(self.min, self.max)
} else if range_part.contains('-') {
let bounds: Vec<&str> = range_part.split('-').collect();
if bounds.len() != 2 {
return Err(CronError::ComponentError(
"Invalid range syntax in stepping.".to_string(),
));
}
(
bounds[0]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid range start.".to_string()))?,
bounds[1]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid range end.".to_string()))?,
)
} else {
let single_start = range_part
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid start.".to_string()))?;
// If only one number is provided, set the range to go from the start value to the max value.
(single_start, self.max)
};
if start < self.min || end > self.max || start > end {
return Err(CronError::ComponentError(
"Range is out of bounds in stepping.".to_string(),
));
}
// Apply stepping within the range
let mut value = start;
while value <= end {
self.set_bit(value, bit_to_set)?;
value = value.checked_add(step).ok_or_else(|| {
CronError::ComponentError("Value exceeded max after stepping.".to_string())
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::CronError;
#[test]
fn test_new_cron_component() {
let component = CronComponent::new(0, 59, ALL_BIT | LAST_BIT, 0);
assert_eq!(component.min, 0);
assert_eq!(component.max, 59);
// Ensure all bitfields are initialized to NONE_BIT
assert!(component.bitfields.iter().all(|&b| b == NONE_BIT));
// Check that ALL_BIT and LAST_BIT are included in features
assert!(component.features & (ALL_BIT | LAST_BIT) == (ALL_BIT | LAST_BIT));
}
#[test]
fn test_set_bit() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(component.set_bit(10, ALL_BIT).is_ok());
assert!(component.is_bit_set(10, ALL_BIT).unwrap());
}
#[test]
fn test_set_bit_out_of_bounds() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(matches!(
component.set_bit(60, ALL_BIT),
Err(CronError::ComponentError(_))
));
}
#[test]
fn test_unset_bit() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.set_bit(10, ALL_BIT).unwrap();
assert!(component.unset_bit(10, ALL_BIT).is_ok());
assert!(!component.is_bit_set(10, ALL_BIT).unwrap());
}
#[test]
fn test_is_feature_enabled() {
let mut component = CronComponent::new(0, 59, LAST_BIT, 0);
assert!(!component.is_feature_enabled(LAST_BIT));
component.enable_feature(LAST_BIT).unwrap();
assert!(component.is_feature_enabled(LAST_BIT));
}
#[test]
fn test_enable_feature_unsupported() {
let mut component = CronComponent::new(0, 59, NONE_BIT, 0);
assert!(matches!(
component.enable_feature(NTH_1ST_BIT),
Err(CronError::ComponentError(_))
));
}
#[test]
fn test_parse_asterisk() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("*").unwrap();
for i in 0..=59 {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_range() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("10-15").unwrap();
for i in 10..=15 {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_stepping() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("*/5").unwrap();
for i in (0..=59).filter(|n| n % 5 == 0) {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_list() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("5,10,15").unwrap();
for i in [5, 10, 15].iter() {
assert!(component.is_bit_set(*i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_invalid_syntax() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(component.parse("10-").is_err());
assert!(component.parse("*/").is_err());
assert!(component.parse("60").is_err()); // out of bounds for the minute field
}
#[test]
fn test_parse_closest_weekday() {
let mut component = CronComponent::new(1, 31, CLOSEST_WEEKDAY_BIT, 0);
component.parse("15W").unwrap();
assert!(component.is_bit_set(15, CLOSEST_WEEKDAY_BIT).unwrap());
// You might want to add more tests for edge cases
}
}
@@ -0,0 +1,53 @@
use crate::describe::Language;
#[derive(Default, Clone, Copy)]
pub struct English;
impl Language for English {
fn every_minute(&self) -> &'static str { "Every minute" }
fn every_second_phrase(&self) -> &'static str { "Every second" }
fn every_x_minutes(&self, s: u16) -> String { format!("every {s} minutes") }
fn every_x_seconds(&self, s: u16) -> String { format!("every {s} seconds") }
fn every_x_hours(&self, s: u16) -> String { format!("of every {s} hours") }
fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Every minute, of every {s} hours") }
fn at_time(&self, time: &str) -> String { format!("At {time}") }
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("At {time}, every {step} seconds") }
fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("At {time}, at second {second}") }
fn at_phrase(&self, phrase: &str) -> String { format!("At {phrase}") }
fn on_phrase(&self, phrase: &str) -> String { format!("on {phrase}") }
fn in_phrase(&self, phrase: &str) -> String { format!("in {phrase}") }
fn second_phrase(&self, s: &str) -> String { format!("second {s}") }
fn minute_phrase(&self, s: &str) -> String { format!("minute {s}") }
fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} past every hour") }
fn hour_phrase(&self, s: &str) -> String { format!("of hour {s}") }
fn year_phrase(&self, s: &str) -> String { format!("year {s}") }
fn day_phrase(&self, s: &str) -> String { format!("day {s}") }
fn the_last_day_of_the_month(&self) -> &'static str { "the last day of the month" }
fn the_weekday_nearest_day(&self, day: &str) -> String { format!("the weekday nearest day {day}") }
fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("the last {day} of the month") }
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String {
let suffix = match n {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
};
let num_str = format!("{n}{suffix}");
format!("the {num_str} {day} of the month")
}
fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(if it is also {dow})") }
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(if it is also one of: {dow})") }
fn list_conjunction_and(&self) -> &'static str { "and" }
fn list_conjunction_or(&self) -> &'static str { "or" }
fn list_conjunction_and_comma(&self) -> &'static str { ", and" }
fn day_of_week_names(&self) -> [&'static str; 7] { ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] }
fn month_names(&self) -> [&'static str; 12] { ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] }
}
+2
View File
@@ -0,0 +1,2 @@
pub mod english;
pub mod swedish;
@@ -0,0 +1,54 @@
use crate::describe::Language;
#[derive(Default, Clone, Copy)]
pub struct Swedish;
impl Language for Swedish {
fn every_minute(&self) -> &'static str { "Varje minut" }
fn every_second_phrase(&self) -> &'static str { "Varje sekund" }
fn every_x_minutes(&self, s: u16) -> String { format!("var {s}:e minut") }
fn every_x_seconds(&self, s: u16) -> String { format!("var {s}:e sekund") }
fn every_x_hours(&self, s: u16) -> String { format!("var {s}:e timme") }
fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Varje minut, var {s}:e timme") }
fn at_time(&self, time: &str) -> String { format!("Klockan {time}") }
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("Klockan {time}, var {step}:e sekund") }
fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("Klockan {time}, på sekund {second}") }
fn at_phrase(&self, phrase: &str) -> String { format!("Vid {phrase}") }
fn on_phrase(&self, phrase: &str) -> String { format!("på {phrase}") }
fn in_phrase(&self, phrase: &str) -> String { format!("i {phrase}") }
fn second_phrase(&self, s: &str) -> String { format!("sekund {s}") }
fn minute_phrase(&self, s: &str) -> String { format!("minut {s}") }
fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} över varje heltimme") }
fn hour_phrase(&self, s: &str) -> String { format!("timme {s}") }
fn year_phrase(&self, s: &str) -> String { format!("år {s}") }
fn day_phrase(&self, s: &str) -> String { format!("dag {s}") }
fn the_last_day_of_the_month(&self) -> &'static str { "sista dagen i månaden" }
fn the_weekday_nearest_day(&self, day: &str) -> String { format!("veckodagen närmast dag {day}") }
fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("sista {day} i månaden") }
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String {
let ordinal = match n {
1 => "första",
2 => "andra",
3 => "tredje",
4 => "fjärde",
5 => "femte",
_ => "", // Should not happen with cron's # specifier
};
format!("den {ordinal} {day} i månaden")
}
fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(om det också är {dow})") }
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(om det också är en av: {dow})") }
fn list_conjunction_and(&self) -> &'static str { "och" }
fn list_conjunction_or(&self) -> &'static str { "eller" }
fn list_conjunction_and_comma(&self) -> &'static str { "och" } // Oxford comma is not used in Swedish
fn day_of_week_names(&self) -> [&'static str; 7] { ["söndag", "måndag", "tisdag", "onsdag", "torsdag", "fredag", "lördag"] }
fn month_names(&self) -> [&'static str; 12] { ["januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december"] }
}
+642
View File
@@ -0,0 +1,642 @@
pub mod lang;
pub use lang::english::English;
use crate::component::{
CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT,
NTH_4TH_BIT, NTH_5TH_BIT,
};
use crate::pattern::CronPattern;
// This defines the contract for providing localized strings.
pub trait Language {
fn every_minute(&self) -> &'static str;
fn every_second_phrase(&self) -> &'static str;
fn every_x_minutes(&self, step: u16) -> String; // Changed to u16
fn every_x_seconds(&self, step: u16) -> String; // Changed to u16
fn every_x_hours(&self, step: u16) -> String; // Changed to u16
fn every_minute_of_every_x_hours(&self, step: u16) -> String; // Changed to u16
fn at_time(&self, time: &str) -> String;
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String; // Changed to u16
fn at_time_at_second(&self, time: &str, second: &str) -> String;
fn at_phrase(&self, phrase: &str) -> String;
fn on_phrase(&self, phrase: &str) -> String;
fn in_phrase(&self, phrase: &str) -> String;
fn second_phrase(&self, s: &str) -> String;
fn minute_phrase(&self, s: &str) -> String;
fn minute_past_every_hour_phrase(&self, s: &str) -> String;
fn hour_phrase(&self, s: &str) -> String;
fn year_phrase(&self, s: &str) -> String; // New for year
fn day_phrase(&self, s: &str) -> String;
fn the_last_day_of_the_month(&self) -> &'static str;
fn the_weekday_nearest_day(&self, day: &str) -> String;
fn the_last_weekday_of_the_month(&self, day: &str) -> String;
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String;
fn dom_and_dow_if_also(&self, dow: &str) -> String;
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String;
fn list_conjunction_and(&self) -> &'static str;
fn list_conjunction_or(&self) -> &'static str;
fn list_conjunction_and_comma(&self) -> &'static str;
fn day_of_week_names(&self) -> [&'static str; 7];
fn month_names(&self) -> [&'static str; 12];
}
/// Generates a human-readable description for a `CronPattern`.
pub fn describe<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let time_desc = describe_time(pattern, lang);
let day_desc = describe_day(pattern, lang);
let month_desc = describe_month(pattern, lang);
let year_desc = describe_year(pattern, lang); // Add year description
let mut parts = vec![];
if !time_desc.is_empty() {
parts.push(time_desc);
}
if !day_desc.is_empty() {
parts.push(day_desc);
}
if !month_desc.is_empty() {
parts.push(month_desc);
}
if !year_desc.is_empty() {
parts.push(year_desc);
}
let mut description = parts.join(", ");
if !description.is_empty() {
let mut chars = description.chars();
description = match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
};
description.push('.');
}
description
}
/// Helper function to determine if a component is fully set (like a wildcard `*`).
fn is_all_set(component: &CronComponent) -> bool {
// We can't just check for a wildcard flag anymore.
// A component is "all set" if every possible value is included.
if component.step != 1 {
return false;
}
let total_values = (component.max - component.min + 1) as usize;
// Handle large ranges efficiently
if total_values > 10000 { // Heuristic for very large ranges like year
return component.from_wildcard;
}
let set_values = (component.min..=component.max)
.filter(|i| component.is_bit_set(*i, ALL_BIT).unwrap_or(false)) // Corrected
.count();
total_values == set_values
}
fn describe_time<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let sec_vals = get_set_values(&pattern.seconds, ALL_BIT);
let min_vals = get_set_values(&pattern.minutes, ALL_BIT);
let hour_vals = get_set_values(&pattern.hours, ALL_BIT);
let is_default_seconds = pattern.seconds.step == 1 && sec_vals.len() == 1 && sec_vals[0] == 0;
let is_every_second = is_all_set(&pattern.seconds);
// Heuristic to detect `*/step` patterns, replacing `from_wildcard`.
let is_stepped_from_start =
|step: u16, vals: &[u16], min: u16| step > 1 && !vals.is_empty() && vals[0] == min;
// Handle simplest cases first
if is_every_second && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) {
return lang.every_second_phrase().to_string();
}
if is_default_seconds && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) {
return lang.every_minute().to_string();
}
if is_default_seconds
&& is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min)
&& is_all_set(&pattern.hours)
{
return lang.at_phrase(&lang.every_x_minutes(pattern.minutes.step));
}
// Handle specific HH:MM time
if !is_every_second
&& pattern.hours.step == 1 && hour_vals.len() == 1
&& pattern.minutes.step == 1 && min_vals.len() == 1
{
let time_str = format!("{:02}:{:02}", hour_vals[0], min_vals[0]);
if !is_default_seconds {
if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) {
return lang.at_time_and_every_x_seconds(&time_str, pattern.seconds.step);
}
if sec_vals.len() == 1 {
return lang.at_time(&format!("{}:{:02}", time_str, sec_vals[0]));
}
return lang.at_time_at_second(&time_str, &format_number_list(&sec_vals, lang));
}
return lang.at_time(&time_str);
}
// Special case: "* 0 * * *" -> "Every minute past hour 0"
// When minutes are all set (wildcard) and hours are specific (not all set)
if is_default_seconds && is_all_set(&pattern.minutes) && !is_all_set(&pattern.hours) {
let hour_desc = if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
lang.every_x_hours(pattern.hours.step)
} else {
format!("hour {}", format_number_list(&hour_vals, lang))
};
return format!("{} past {}", lang.every_minute(), hour_desc);
}
// Special case: "* * 0 * * *" -> "Every second past hour 0"
// When seconds and minutes are all set (wildcard) and hours are specific (not all set)
if is_every_second && is_all_set(&pattern.minutes) && !is_all_set(&pattern.hours) {
let hour_desc = if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
lang.every_x_hours(pattern.hours.step)
} else {
format!("hour {}", format_number_list(&hour_vals, lang))
};
return format!("{} past {}", lang.every_second_phrase(), hour_desc);
}
// Handle all other complex combinations
let mut parts = vec![];
if is_every_second {
parts.push(lang.every_second_phrase().to_string());
} else if !is_default_seconds {
if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) {
parts.push(lang.every_x_seconds(pattern.seconds.step));
} else {
parts.push(lang.second_phrase(&format_number_list(&sec_vals, lang)));
}
}
if is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min) {
parts.push(lang.every_x_minutes(pattern.minutes.step));
} else if !is_all_set(&pattern.minutes) {
let min_desc = lang.minute_phrase(&format_number_list(&min_vals, lang));
if is_all_set(&pattern.hours) && pattern.hours.step == 1 {
parts.push(lang.minute_past_every_hour_phrase(&min_desc));
} else {
parts.push(min_desc);
}
}
if !is_all_set(&pattern.hours) {
if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
parts.push(lang.every_x_hours(pattern.hours.step));
} else {
parts.push(lang.hour_phrase(&format_number_list(&hour_vals, lang)));
}
}
if parts.is_empty() {
return lang.every_minute().to_string();
}
if parts.len() > 1 && parts[0] == lang.every_second_phrase() {
return parts.join(", ");
}
lang.at_phrase(&parts.join(", "))
}
fn get_set_values(component: &CronComponent, bit: u8) -> Vec<u16> {
(component.min..=component.max)
.filter(|i| component.is_bit_set(*i, bit).unwrap_or(false)) // Corrected
.collect()
}
fn format_text_list<L: Language>(items: Vec<String>, lang: &L) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} {} {}", items[0], lang.list_conjunction_and(), items[1]),
_ => {
if let Some(last) = items.last() {
let front = &items[..items.len() - 1];
format!("{}, {} {}", front.join(", "), lang.list_conjunction_and(), last)
} else {
String::new()
}
}
}
}
fn format_number_list<L: Language>(values: &[u16], lang: &L) -> String {
if values.is_empty() {
return String::new();
}
let mut sorted_values = values.to_vec();
sorted_values.sort_unstable();
let mut items = vec![];
let mut i = 0;
while i < sorted_values.len() {
let start = sorted_values[i];
let mut j = i;
while j + 1 < sorted_values.len() && sorted_values[j + 1] == sorted_values[j] + 1 {
j += 1;
}
if j > i + 1 { // Only create a range for 3 or more consecutive numbers
items.push(format!("{}-{}", start, sorted_values[j]));
} else {
for k in sorted_values.iter().take(j + 1).skip(i) {
items.push(k.to_string());
}
}
i = j + 1;
}
format_text_list(items, lang)
}
fn describe_day<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let dom_desc = describe_dom(pattern, lang);
let dow_parts = describe_dow_parts(pattern, lang);
if pattern.star_dom && pattern.star_dow {
return "".to_string();
}
if !pattern.star_dom && pattern.star_dow {
return lang.on_phrase(&dom_desc);
}
let dow_desc = format_text_list(dow_parts.clone(), lang);
if pattern.star_dom && !pattern.star_dow {
return lang.on_phrase(&dow_desc);
}
if pattern.dom_and_dow {
let final_phrase = if dow_parts.len() > 1 {
lang.dom_and_dow_if_also_one_of(&dow_desc)
} else {
lang.dom_and_dow_if_also(&dow_desc)
};
format!("{} {}", lang.on_phrase(&dom_desc), final_phrase)
} else {
format!("{} {} {}", lang.on_phrase(&dom_desc), lang.list_conjunction_or(), dow_desc)
}
}
fn describe_dom<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let mut parts = vec![];
let regular_days = get_set_values(&pattern.days, ALL_BIT);
if !regular_days.is_empty() {
parts.push(lang.day_phrase(&format_number_list(&regular_days, lang)));
}
if pattern.days.is_feature_enabled(LAST_BIT) {
parts.push(lang.the_last_day_of_the_month().to_string());
}
let weekday_values = get_set_values(&pattern.days, CLOSEST_WEEKDAY_BIT);
if !weekday_values.is_empty() {
parts.push(lang.the_weekday_nearest_day(&format_number_list(&weekday_values, lang)));
}
format_text_list(parts, lang)
}
fn describe_dow_parts<L: Language>(pattern: &CronPattern, lang: &L) -> Vec<String> {
let mut parts = vec![];
let dow_names_map = lang.day_of_week_names();
// The `with_alternative_weekdays` flag is gone. Parser normalizes DOW.
// This mapping handles the 0-7 range where 7 is Sunday.
let dow_names = &[
dow_names_map[0], dow_names_map[1], dow_names_map[2], dow_names_map[3],
dow_names_map[4], dow_names_map[5], dow_names_map[6], dow_names_map[0],
];
let last_values = get_set_values(&pattern.days_of_week, LAST_BIT);
if !last_values.is_empty() {
let days = last_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(lang.the_last_weekday_of_the_month(&format_text_list(days, lang)));
}
for (i, nth_bit) in [NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT].iter().enumerate() {
let values = get_set_values(&pattern.days_of_week, *nth_bit);
if !values.is_empty() {
let days = values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(lang.the_nth_weekday_of_the_month((i + 1) as u8, &format_text_list(days, lang)));
}
}
let regular_values = get_set_values(&pattern.days_of_week, ALL_BIT);
if !regular_values.is_empty() {
let list = regular_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(format_text_list(list, lang));
}
parts
}
fn describe_month<L: Language>(pattern: &CronPattern, lang: &L) -> String {
if is_all_set(&pattern.months) {
return "".to_string();
}
let month_names = lang.month_names();
if pattern.months.step > 1 {
return lang.in_phrase(&format!("every {} months", pattern.months.step));
}
let values = get_set_values(&pattern.months, ALL_BIT);
let list = values
.iter()
.map(|v| month_names[*v as usize - 1].to_string()) // Corrected
.collect::<Vec<_>>();
lang.in_phrase(&format_text_list(list, lang))
}
fn describe_year<L: Language>(pattern: &CronPattern, lang: &L) -> String {
if is_all_set(&pattern.years) {
return "".to_string();
}
if pattern.years.step > 1 {
return lang.in_phrase(&lang.year_phrase(&format!("every {}", pattern.years.step)));
}
let values = get_set_values(&pattern.years, ALL_BIT);
lang.in_phrase(&lang.year_phrase(&format_number_list(&values, lang)))
}
#[cfg(test)]
mod tests {
use super::lang::english::English;
use crate::parser::{CronParser, Seconds, Year};
use super::Language;
// Updated helper to use the new parser API
fn get_description_lang_config<L: Language + Default>(
pattern_str: &str,
lang: L,
seconds: Seconds,
year: Year,
dom_and_dow: bool,
) -> String {
let cron = CronParser::builder()
.seconds(seconds)
.year(year)
.dom_and_dow(dom_and_dow)
.build()
.parse(pattern_str)
.expect("Failed to parse pattern for test");
// The user wants to test the describe function in this module
super::describe(&cron.pattern, &lang)
}
// Simplified helper for common cases.
// It uses a permissive parser config that can handle any pattern
// since the parser normalizes the pattern before describe is called.
fn get_description(pattern_str: &str) -> String {
get_description_lang_config(
pattern_str,
English,
Seconds::Optional, // Be permissive
Year::Optional, // Be permissive
false,
)
}
#[test]
fn test_time_descriptions() {
assert_eq!(get_description("* * * * *"), "Every minute.");
assert_eq!(get_description("*/15 * * * *"), "At every 15 minutes.");
assert_eq!(get_description("0 * * * *"), "At minute 0 past every hour.");
assert_eq!(get_description("0 14 * * *"), "At 14:00.");
assert_eq!(
get_description("2,4,6 * * * *"),
"At minute 2, 4, and 6 past every hour."
);
assert_eq!(
get_description("0 0-6 * * *"),
"At minute 0, of hour 0-6."
);
assert_eq!(
get_description("0 */2 * * *"),
"At minute 0, of every 2 hours."
);
// Test for issue #35: "* 0 * * *" should describe properly
assert_eq!(
get_description("* 0 * * *"),
"Every minute past hour 0."
);
assert_eq!(
get_description("* 0,12 * * *"),
"Every minute past hour 0 and 12."
);
}
#[test]
fn test_seconds_descriptions() {
assert_eq!(get_description("*/10 * * * * *"), "At every 10 seconds.");
assert_eq!(get_description("30 0 14 * * *"), "At 14:00:30.");
assert_eq!(
get_description("10-20 0 14 * * *"),
"At 14:00, at second 10-20."
);
// Test for similar issue as #35 with seconds
assert_eq!(
get_description("* * 0 * * *"),
"Every second past hour 0."
);
assert_eq!(
get_description("* * 5 * * *"),
"Every second past hour 5."
);
}
#[test]
fn test_year_descriptions() {
assert_eq!(
get_description("0 0 0 1 1 * 2025"),
"At 00:00, on day 1, in January, in year 2025."
);
assert_eq!(
get_description("0 0 0 1 1 * 2025-2030"),
"At 00:00, on day 1, in January, in year 2025-2030."
);
}
#[test]
fn test_day_descriptions() {
assert_eq!(get_description("0 12 * * MON"), "At 12:00, on Monday.");
assert_eq!(
get_description("0 12 * * 1-5"),
"At 12:00, on Monday, Tuesday, Wednesday, Thursday, and Friday."
);
assert_eq!(get_description("0 12 15 * *"), "At 12:00, on day 15.");
assert_eq!(
get_description("0 12 L * *"),
"At 12:00, on the last day of the month."
);
assert_eq!(
get_description("0 12 1,15 * *"),
"At 12:00, on day 1 and 15."
);
}
#[test]
fn test_month_descriptions() {
assert_eq!(get_description("* * * JAN *"), "Every minute, in January.");
assert_eq!(
get_description("* * * 1,3,5 *"),
"Every minute, in January, March, and May."
);
}
#[test]
fn test_special_char_descriptions() {
assert_eq!(
get_description("* * * * 5L"),
"Every minute, on the last Friday of the month."
);
assert_eq!(
get_description("* * * * TUE#3"),
"Every minute, on the 3rd Tuesday of the month."
);
assert_eq!(
get_description("* * 15W * *"),
"Every minute, on the weekday nearest day 15."
);
}
#[test]
fn test_dom_and_dow_logic() {
// Default behavior (OR)
let or_desc = get_description("0 0 15 * FRI");
assert_eq!(or_desc, "At 00:00, on day 15 or Friday.");
// AND behavior
let and_desc =
get_description_lang_config("0 0 15 * FRI", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
and_desc,
"At 00:00, on day 15 (if it is also Friday)."
);
}
#[test]
fn test_complex_combinations() {
assert_eq!(
get_description("30 18 15,L MAR *"),
"At 18:30, on day 15 and the last day of the month, in March."
);
let and_desc = get_description_lang_config("30 18 15,L MAR FRI", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
and_desc,
"At 18:30, on day 15 and the last day of the month (if it is also Friday), in March."
);
}
#[test]
fn test_second_and_minute_steps() {
assert_eq!(
get_description("* */2 * * * *"),
"Every second, every 2 minutes."
)
}
#[test]
fn test_ranged_steps() {
assert_eq!(
get_description("18-28/2 * * * * *"),
"At second 18, 20, 22, 24, 26, and 28."
);
}
#[test]
fn test_complex_dom_and_dow() {
let desc = get_description_lang_config("0 0 1 * FRI#L,MON#1", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
desc,
"At 00:00, on day 1 (if it is also one of: the last Friday of the month and the 1st Monday of the month)."
);
}
// Issue #35: Incorrect descriptor
// https://github.com/Hexagon/croner-rust/issues/35
// Pattern "* 0 * * *" was producing "At of hour 0." instead of "Every minute past hour 0."
#[test]
fn test_issue_35_wildcard_minutes_specific_hours() {
// Original bug: "* 0 * * *" produced "At of hour 0."
assert_eq!(
get_description("* 0 * * *"),
"Every minute past hour 0."
);
assert_eq!(
get_description("* 5 * * *"),
"Every minute past hour 5."
);
assert_eq!(
get_description("* 0-5 * * *"),
"Every minute past hour 0-5."
);
}
#[test]
fn test_issue_35_seconds_variant() {
// Similar issue with seconds: "* * 0 * * *" was producing "Every second, of hour 0."
assert_eq!(
get_description("* * 0 * * *"),
"Every second past hour 0."
);
assert_eq!(
get_description("* * 5 * * *"),
"Every second past hour 5."
);
assert_eq!(
get_description("* * 0,12 * * *"),
"Every second past hour 0 and 12."
);
}
#[test]
fn test_issue_35_with_other_fields() {
// Test combinations with days, months, weekdays
assert_eq!(
get_description("* 0 * 1 *"),
"Every minute past hour 0, in January."
);
assert_eq!(
get_description("* 0 * * MON"),
"Every minute past hour 0, on Monday."
);
}
#[test]
fn test_no_grammatical_errors() {
// Ensure no grammatical errors like "At of", "At ,", etc.
let patterns = vec![
"* 0 * * *",
"* * 0 * * *",
"0 * 0 * * *",
"* 0 * 1 *",
"* 0 * * MON",
];
for pattern in patterns {
let desc = get_description(pattern);
assert!(
!desc.contains("At of") && !desc.contains("At ,") && !desc.starts_with("At ."),
"Pattern '{}' produced grammatically incorrect description: '{}'",
pattern,
desc
);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/// Represents errors that can occur while parsing and evaluating cron patterns.
///
/// `CronError` is used throughout the `croner` crate to indicate various types of failures
/// and is exported for consuming programs to use.
#[derive(Debug)]
pub enum CronError {
/// The pattern string provided was empty.
///
/// This error occurs if a cron pattern is set to an empty string, which is not a valid cron expression.
EmptyPattern,
/// Encountered an invalid date while parsing or evaluating a cron pattern.
///
/// This might happen if a cron pattern results in a date that doesn't exist (e.g., February 30th).
InvalidDate,
/// Encountered an invalid time while parsing or evaluating a cron pattern.
///
/// This can occur if a time component in the cron pattern is outside its valid range.
InvalidTime,
/// The search for the next valid time exceeded a reasonable limit.
///
/// This is typically encountered with complex patterns that don't match any real-world times.
TimeSearchLimitExceeded,
/// The cron pattern provided is invalid.
///
/// This error includes a message detailing the nature of the invalid pattern,
/// such as "Pattern must consist of six fields, seconds can not be omitted."
InvalidPattern(String),
/// The pattern contains characters that are not allowed.
///
/// This error includes a message indicating the illegal characters encountered in the pattern,
/// such as "CronPattern contains illegal characters."
IllegalCharacters(String),
/// A component of the pattern is invalid.
///
/// This variant is used for various errors that specifically arise from individual components of a cron pattern,
/// such as "Position x is out of bounds for the current range (y-z).".
ComponentError(String),
}
impl std::fmt::Display for CronError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
CronError::TimeSearchLimitExceeded => {
write!(f, "CronScheduler time search limit exceeded.")
}
CronError::EmptyPattern => write!(f, "CronPattern cannot be an empty string."),
CronError::InvalidDate => write!(f, "CronScheduler encountered an invalid date."),
CronError::InvalidTime => write!(f, "CronScheduler encountered an invalid time."),
CronError::InvalidPattern(msg) => write!(f, "Invalid pattern: {msg}"),
CronError::IllegalCharacters(msg) => {
write!(f, "Pattern contains illegal characters: {msg}")
}
CronError::ComponentError(msg) => write!(f, "Component error: {msg}"),
}
}
}
impl std::error::Error for CronError {}
+118
View File
@@ -0,0 +1,118 @@
use crate::{Cron, CronError, Direction};
use chrono::{DateTime, TimeZone, Duration};
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash)]
pub struct CronIterator<Tz>
where
Tz: TimeZone,
{
cron: Cron,
current_time: DateTime<Tz>,
is_first: bool,
inclusive: bool,
direction: Direction,
pending_ambiguous_dt: Option<DateTime<Tz>>,
}
impl<Tz> CronIterator<Tz>
where
Tz: TimeZone,
{
/// Creates a new `CronIterator`.
///
/// # Arguments
///
/// * `cron` - The `Cron` schedule instance.
/// * `start_time` - The `DateTime` to start iterating from.
/// * `inclusive` - Whether the `start_time` should be included in the results if it matches.
/// * `direction` - The direction to iterate in (Forward or Backward).
pub fn new(
cron: Cron,
start_time: DateTime<Tz>,
inclusive: bool,
direction: Direction,
) -> Self {
CronIterator {
cron,
current_time: start_time,
is_first: true,
inclusive,
direction,
pending_ambiguous_dt: None,
}
}
}
impl<Tz> Iterator for CronIterator<Tz>
where
Tz: TimeZone + Clone + Copy,
{
type Item = DateTime<Tz>;
fn next(&mut self) -> Option<Self::Item> {
// Step 1: Check for and yield a pending ambiguous datetime first.
// This handles the second occurrence of a time during DST fallback.
if let Some(pending_dt_to_yield) = self.pending_ambiguous_dt.take() {
// After yielding the second ambiguous time, advance current_time past it.
// Clone pending_dt_to_yield because it's about to be returned,
// but we need its value to calculate the next `self.current_time`.
self.current_time = pending_dt_to_yield.clone().checked_add_signed(match self.direction { // Fixed E0382: pending_dt_to_yield
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
return Some(pending_dt_to_yield);
}
// Determine if the search should be inclusive based on whether it's the first run.
let inclusive_search = if self.is_first {
self.is_first = false;
self.inclusive
} else {
false // Subsequent searches are always exclusive of the last actual point in time.
};
let result = self.cron.find_occurrence(&self.current_time, inclusive_search, self.direction);
match result {
Ok((found_time, optional_second_ambiguous_dt)) => {
// This `found_time` is the one we will return in this iteration.
// If there's a second ambiguous datetime (for interval jobs),
// store it to be yielded on the *next* call to next().
// And importantly, set `self.current_time` to advance *past* this second ambiguous time
// so the *next* search for a *new* naive time is correct.
if let Some(second_ambiguous_dt) = optional_second_ambiguous_dt {
// Clone second_ambiguous_dt because it's stored in self.pending_ambiguous_dt
// AND used to calculate the next self.current_time.
self.pending_ambiguous_dt = Some(second_ambiguous_dt.clone()); // Fixed E0382: second_ambiguous_dt
// Advance `self.current_time` past the latest of the ambiguous pair.
// This ensures the next `find_occurrence` call searches for the next unique naive time.
self.current_time = second_ambiguous_dt.checked_add_signed(match self.direction {
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
} else {
// Case: No second ambiguous time (either not an overlap, or fixed-time job).
// Advance `self.current_time` simply past the `found_time`.
// Clone found_time because it's used to calculate the next self.current_time
// AND returned at the end of this block.
self.current_time = found_time.clone().checked_add_signed(match self.direction { // Fixed E0382: found_time
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
}
// Finally, return the found_time for the current iteration.
// This `found_time` is the original value received from `find_occurrence`.
Some(found_time)
}
Err(CronError::TimeSearchLimitExceeded) => None,
Err(e) => {
eprintln!("CronIterator encountered an error: {e:?}");
None
}
}
}
}
File diff suppressed because it is too large Load Diff
+748
View File
@@ -0,0 +1,748 @@
//! Parser for Cron patterns.
//!
//! Croner uses [`CronParser`] to parse the cron expression. Invoking
//!
//! ```rust
//! # use std::str::FromStr as _;
//! #
//! # use croner::{Cron, parser::CronParser};
//! #
//! Cron::from_str("pattern");
//! ```
//!
//! is equivalent to
//!
//! ```rust
//! # use std::str::FromStr as _;
//! #
//! # use croner::{Cron, parser::CronParser};
//! #
//! CronParser::new().parse("pattern");
//! ```
//!
//! You can customise the parser by creating a parser builder using
//! [`CronParser::builder`]. So, for example, to parse cron patterns with
//! optional seconds do something like this:
//!
//! ```rust
//! use croner::parser::{CronParser, Seconds};
//!
//! // Configure the parser to allow seconds.
//! let parser = CronParser::builder().seconds(Seconds::Optional).build();
//!
//! let cron_with_seconds = parser
//! .parse("*/10 * * * * *")
//! .unwrap();
//! let cron_without_seconds = parser
//! .parse("* * * * *")
//! .unwrap();
//! ```
use derive_builder::Builder;
use strum::EnumIs;
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,
},
errors::CronError,
pattern::CronPattern,
Cron, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)]
pub enum Seconds {
#[default]
Optional,
Required,
Disallowed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)]
pub enum Year {
#[default]
Optional,
Required,
Disallowed,
}
/// Parser for Cron patterns.
///
/// In order to build a custom cron parser use [`CronParser::builder`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Builder)]
#[builder(default, build_fn(skip), pattern = "owned")]
pub struct CronParser {
/// Configure how seconds should be handled.
seconds: Seconds,
/// Configure how years should be handled.
year: Year,
/// Enable the combination of Day of Month (DOM) and Day of Week (DOW) conditions.
dom_and_dow: bool,
/// Use the Quartz-style weekday mode.
alternative_weekdays: bool,
}
impl CronParser {
/// Create a new parser.
///
/// You should probably be using [`Cron`]'s implementation of
/// [`FromStr`][std::str::FromStr] instead of invoking this.
pub fn new() -> Self {
Self::default()
}
/// Construct a builder for custom parsing.
///
/// Equivalent to [`CronParserBuilder::default`].
pub fn builder() -> CronParserBuilder {
CronParserBuilder::default()
}
/// Parses the cron pattern string.
pub fn parse(&self, pattern: &str) -> Result<Cron, CronError> {
// Ensure upper case in parsing, and trim it
let mut pattern: String = pattern.to_uppercase().trim().to_string();
// Should already be trimmed
if pattern.is_empty() {
return Err(CronError::EmptyPattern);
}
// Handle @nicknames
if pattern.contains('@') {
pattern = Self::handle_nicknames(&pattern, self.seconds.is_required(), self.year.is_required()).to_string();
}
// Handle day-of-week and month aliases (MON... and JAN...)
pattern = Self::replace_alpha_weekdays(&pattern, self.alternative_weekdays).to_string();
pattern = Self::replace_alpha_months(&pattern).to_string();
// Split the pattern into parts
let mut parts: Vec<&str> = pattern.split_whitespace().collect();
let num_parts = parts.len();
// Default seconds to "0" if omitted in an optional context
if num_parts == 5 {
parts.insert(0, "0");
} else if self.seconds.is_disallowed() {
return Err(CronError::InvalidPattern("Pattern must have 5 fields when seconds are disallowed.".to_string()));
}
// Default year to "*" if omitted in an optional context
if parts.len() == 6 {
parts.push("*");
} else if self.year.is_disallowed() {
return Err(CronError::InvalidPattern("Pattern must have 5 or 6 fields when years are disallowed.".to_string()));
}
// Validate pattern length based on configuration
if self.seconds.is_required() {
if self.year.is_required() && num_parts != 7 {
return Err(CronError::InvalidPattern("Pattern must have 7 fields when seconds and years are required.".to_string()));
}
if self.year.is_disallowed() && num_parts != 6 {
return Err(CronError::InvalidPattern("Pattern must have 6 fields when seconds are required and years are disallowed.".to_string()));
}
if self.year.is_optional() && !(6..=7).contains(&num_parts) {
return Err(CronError::InvalidPattern("Pattern must have 6 or 7 fields when seconds are required and years are optional.".to_string()));
}
} else if self.year.is_required() && num_parts != 7 {
return Err(CronError::InvalidPattern("Pattern must have 7 fields when years are required.".to_string()));
} else if !(5..=7).contains(&num_parts) {
return Err(CronError::InvalidPattern("Pattern must have between 5 and 7 fields.".to_string()));
}
// Replace ? with * in day-of-month and day-of-week
let mut owned_parts = parts.iter().map(|s| s.to_string()).collect::<Vec<String>>();
if owned_parts.get(3).is_some_and(|p| p.contains('?')) {
owned_parts[3] = owned_parts[3].replace('?', "*");
}
if owned_parts.get(5).is_some_and(|p| p.contains('?')) {
owned_parts[5] = owned_parts[5].replace('?', "*");
}
// Check for the '+' (AND) modifier in the day-of-week field.
// This must be done before illegal character validation.
let mut dom_and_dow_from_pattern = false;
if let Some(dow_part) = owned_parts.get_mut(5) {
if dow_part.starts_with('+') {
dom_and_dow_from_pattern = true;
// Remove the '+' so the rest of the field can be parsed normally.
*dow_part = dow_part[1..].to_string();
}
}
parts = owned_parts.iter().map(|s| s.as_str()).collect();
// Throw at illegal characters
self.throw_at_illegal_characters(&parts)?;
// Handle star-dom and star-dow
let star_dom = parts.get(3).is_some_and(|&p| p == "*");
let star_dow = parts.get(5).is_some_and(|&p| p == "*");
// Parse the individual components
let mut seconds = CronComponent::new(0, 59, NONE_BIT, 0);
seconds.parse(parts[0])?;
let mut minutes = CronComponent::new(0, 59, NONE_BIT, 0);
minutes.parse(parts[1])?;
let mut hours = CronComponent::new(0, 23, NONE_BIT, 0);
hours.parse(parts[2])?;
let mut days = CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0);
days.parse(parts[3])?;
let mut months = CronComponent::new(1, 12, NONE_BIT, 0);
months.parse(parts[4])?;
let mut days_of_week = if self.alternative_weekdays {
CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 1)
} else {
CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0)
};
days_of_week.parse(parts[5])?;
let mut years = CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0); // Placeholder, real limits are i32
years.parse(parts[6])?;
// Handle conversion of 7 to 0 for day_of_week if necessary
if !self.alternative_weekdays {
for nth_bit in [
ALL_BIT,
NTH_1ST_BIT,
NTH_2ND_BIT,
NTH_3RD_BIT,
NTH_4TH_BIT,
NTH_5TH_BIT,
] {
if days_of_week.is_bit_set(7, nth_bit)? {
days_of_week.unset_bit(7, nth_bit)?;
days_of_week.set_bit(0, nth_bit)?;
}
}
}
Ok(Cron {
pattern: CronPattern {
pattern,
seconds,
minutes,
hours,
days,
months,
days_of_week,
years,
star_dom,
star_dow,
dom_and_dow: self.dom_and_dow || dom_and_dow_from_pattern,
},
})
}
// Validates that the cron pattern only contains legal characters for each field.
fn throw_at_illegal_characters(&self, parts: &[&str]) -> Result<(), CronError> {
let base_allowed_characters = [
'*', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ',', '-',
];
let day_of_week_additional_characters = ['#', 'L', '?'];
let day_of_month_additional_characters = ['L', 'W', '?'];
for (i, part) in parts.iter().enumerate() {
// Decide which set of allowed characters to use
let allowed = match i {
3 => [base_allowed_characters.as_ref(), day_of_month_additional_characters.as_ref()].concat(),
5 => [base_allowed_characters.as_ref(), day_of_week_additional_characters.as_ref()].concat(),
// All other fields, including year (index 6) use base characters
_ => base_allowed_characters.to_vec(),
};
for ch in part.chars() {
if !allowed.contains(&ch) {
return Err(CronError::IllegalCharacters(format!(
"CronPattern contains illegal character '{ch}' in part '{part}'"
)));
}
}
}
Ok(())
}
// Converts named cron pattern shortcuts into their equivalent standard cron pattern.
fn handle_nicknames(pattern: &str, with_seconds: bool, with_year: bool) -> String {
let pattern = pattern.trim();
let eq_ignore_case = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
let base_pattern = match pattern {
p if eq_ignore_case(p, "@yearly") || eq_ignore_case(p, "@annually") => "0 0 1 1 *",
p if eq_ignore_case(p, "@monthly") => "0 0 1 * *",
p if eq_ignore_case(p, "@weekly") => "0 0 * * 0",
p if eq_ignore_case(p, "@daily") => "0 0 * * *",
p if eq_ignore_case(p, "@hourly") => "0 * * * *",
_ => pattern,
};
let mut final_pattern = String::new();
if with_seconds {
final_pattern.push_str("0 ");
}
final_pattern.push_str(base_pattern);
if with_year {
final_pattern.push_str(" *");
}
final_pattern
}
// Converts day-of-week nicknames into their equivalent standard cron pattern.
fn replace_alpha_weekdays(pattern: &str, alternative_weekdays: bool) -> String {
let nicknames = if !alternative_weekdays {
[
("-SUN", "-7"),
("SUN", "0"),
("MON", "1"),
("TUE", "2"),
("WED", "3"),
("THU", "4"),
("FRI", "5"),
("SAT", "6"),
]
} else {
[
("-SUN", "-1"),
("SUN", "1"),
("MON", "2"),
("TUE", "3"),
("WED", "4"),
("THU", "5"),
("FRI", "6"),
("SAT", "7"),
]
};
let mut replaced = pattern.to_string();
// Replace nicknames with their numeric values
for &(nickname, value) in &nicknames {
replaced = replaced.replace(nickname, value);
}
replaced
}
// Converts month nicknames into their equivalent standard cron pattern.
fn replace_alpha_months(pattern: &str) -> String {
let nicknames = [
("JAN", "1"),
("FEB", "2"),
("MAR", "3"),
("APR", "4"),
("MAY", "5"),
("JUN", "6"),
("JUL", "7"),
("AUG", "8"),
("SEP", "9"),
("OCT", "10"),
("NOV", "11"),
("DEC", "12"),
];
let mut replaced = pattern.to_string();
// Replace nicknames with their numeric values
for &(nickname, value) in &nicknames {
replaced = replaced.replace(nickname, value);
}
replaced
}
}
impl CronParserBuilder {
pub fn build(self) -> CronParser {
let CronParserBuilder {
seconds,
year,
dom_and_dow,
alternative_weekdays,
} = self;
CronParser {
seconds: seconds.unwrap_or_default(),
year: year.unwrap_or_default(),
dom_and_dow: dom_and_dow.unwrap_or_default(),
alternative_weekdays: alternative_weekdays.unwrap_or_default(),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr as _;
use super::*;
#[test]
fn test_cron_pattern_new() {
let cron = Cron::from_str("*/5 * * * *").unwrap();
assert_eq!(cron.pattern.pattern, "*/5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_new_with_seconds_optional() {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* */5 * * * *")
.expect("Success");
assert_eq!(cron.pattern.pattern, "* */5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_new_with_seconds_required() {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* */5 * * * *")
.unwrap();
assert_eq!(cron.pattern.pattern, "* */5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_tostring() {
let cron = Cron::from_str("*/5 * * * *").unwrap();
assert_eq!(cron.to_string(), "*/5 * * * *");
}
#[test]
fn test_cron_pattern_short() {
let cron = Cron::from_str("5/5 * * * *").unwrap();
assert_eq!(cron.pattern.pattern, "5/5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
assert!(!cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap());
assert!(!cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_parse() {
let cron = Cron::from_str("*/15 1 1,15 1 1-5").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_extra_whitespace() {
let cron = Cron::from_str(" */15 1 1,15 1 1-5 ").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_leading_zeros() {
let cron = Cron::from_str(" */15 01 01,15 01 01-05 ").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_handle_nicknames() {
assert_eq!(CronParser::handle_nicknames("@yearly", false, false), "0 0 1 1 *");
assert_eq!(CronParser::handle_nicknames("@monthly", false, false), "0 0 1 * *");
assert_eq!(CronParser::handle_nicknames("@weekly", false, false), "0 0 * * 0");
assert_eq!(CronParser::handle_nicknames("@daily", false, false), "0 0 * * *");
assert_eq!(CronParser::handle_nicknames("@hourly", false, false), "0 * * * *");
}
#[test]
fn test_cron_pattern_handle_nicknames_with_seconds_required() {
assert_eq!(CronParser::handle_nicknames("@yearly", true, false), "0 0 0 1 1 *");
assert_eq!(
CronParser::handle_nicknames("@monthly", true, false),
"0 0 0 1 * *"
);
assert_eq!(CronParser::handle_nicknames("@weekly", true, false), "0 0 0 * * 0");
assert_eq!(CronParser::handle_nicknames("@daily", true, false), "0 0 0 * * *");
assert_eq!(CronParser::handle_nicknames("@hourly", true, false), "0 0 * * * *");
}
#[test]
fn test_month_nickname_range() {
let cron = Cron::from_str("0 0 * FEB-MAR *").unwrap();
assert!(!cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap());
assert!(cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()); // February
assert!(cron.pattern.months.is_bit_set(3, ALL_BIT).unwrap()); // March
assert!(!cron.pattern.months.is_bit_set(4, ALL_BIT).unwrap());
}
#[test]
fn test_weekday_range_sat_sun() {
let cron = Cron::from_str("0 0 * * SAT-SUN").unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Sunday
assert!(cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday
}
#[test]
fn test_with_seconds_false() {
// Explicitly create a parser that disallows seconds
let parser = CronParser::builder()
.seconds(Seconds::Disallowed)
.build();
// Test with a 6-part pattern when seconds are not allowed
let error = parser.parse("* * * * * *").unwrap_err();
assert!(matches!(error, CronError::InvalidPattern(_)));
// Test with a 5-part pattern when seconds are not allowed
let no_seconds_pattern = parser.parse("*/10 * * * *").unwrap();
assert_eq!(no_seconds_pattern.to_string(), "*/10 * * * *");
// Ensure seconds are defaulted to 0 for a 5-part pattern
assert!(no_seconds_pattern
.pattern
.seconds
.is_bit_set(0, ALL_BIT)
.unwrap());
}
#[test]
fn test_with_seconds_required() {
// Test with a 5-part pattern when seconds are required
let no_seconds_pattern = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("*/10 * * * *")
.unwrap_err();
assert!(matches!(no_seconds_pattern, CronError::InvalidPattern(_)));
// Test with a 6-part pattern when seconds are required
let cron = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("* * * * * *")
.unwrap();
// Ensure the 6-part pattern retains seconds information
// (This assertion depends on how your CronPattern is structured and how it stores seconds information)
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
}
#[test]
fn test_with_alternative_weekdays() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * MON-FRI")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday
assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday
assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set
}
#[test]
fn test_with_alternative_weekdays_numeric() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 2-6")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday
assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday
assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set
}
#[test]
fn test_seven_to_zero() {
// Test with alternative weekdays enabled
let cron = Cron::from_str("* * * * 7").unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday
}
#[test]
fn test_one_is_monday_alternative() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 1")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday
}
#[test]
fn test_zero_with_alternative_weekdays_fails() {
// Test with alternative weekdays enabled
let error = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 0")
.unwrap_err();
// Parsing should raise a ComponentError
assert!(matches!(error, CronError::ComponentError(_)));
}
#[test]
fn test_question_mark_allowed_in_day_of_month() {
let pattern = "* * ? * *";
assert!(
Cron::from_str(pattern).is_ok(),
"Should allow '?' in the day-of-month field."
);
}
#[test]
fn test_question_mark_allowed_in_day_of_week() {
let pattern = "* * * * ?";
assert!(
Cron::from_str(pattern).is_ok(),
"Should allow '?' in the day-of-week field."
);
}
#[test]
fn test_question_mark_disallowed_in_minute() {
let pattern = "? * * * *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the minute field."
);
}
#[test]
fn test_question_mark_disallowed_in_hour() {
let pattern = "* ? * * *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the hour field."
);
}
#[test]
fn test_question_mark_disallowed_in_month() {
let pattern = "* * * ? *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the month field."
);
}
#[test]
fn test_case_sensitivity_lowercase_special_character_ok() {
let pattern = "* * 15w * *";
let result = Cron::from_str(pattern);
assert!(
result.is_ok(),
"Should allow lowercase special character w."
);
}
#[test]
fn test_case_sensitivity_uppercase_special_character_ok() {
let pattern = "* * 15W * *";
let result: Result<Cron, CronError> = Cron::from_str(pattern);
assert!(
result.is_ok(),
"Should allow uppercase special character W."
);
}
#[test]
fn test_year_support() {
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Optional)
.build();
// 7-field pattern
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
// 6-field pattern (year defaults to *)
assert!(parser.parse("0 0 0 1 1 *").is_ok());
// 5-field pattern (seconds defaults to 0, year to *)
assert!(parser.parse("0 0 1 1 *").is_ok());
}
#[test]
fn test_year_required() {
let parser = CronParser::builder()
.seconds(Seconds::Required)
.year(Year::Required)
.build();
// Must have 7 fields
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
// 6 fields should fail
assert!(parser.parse("0 0 0 1 1 *").is_err());
}
#[test]
fn test_optional_seconds_and_required_year_fails_on_six_parts() {
// This parser configuration should only accept 7-part patterns.
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Required)
.build();
// A 6-part pattern should fail because the year is missing but required.
let result = parser.parse("* * * * * *");
assert!(matches!(result, Err(CronError::InvalidPattern(_))), "Should fail when year is required but not provided.");
}
}
+547
View File
@@ -0,0 +1,547 @@
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(())
}
}