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,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Builder)]
#[builder(default, build_fn(skip), pattern = "owned")]
pub struct CronParser {
seconds: Seconds,
year: Year,
dom_and_dow: bool,
alternative_weekdays: bool,
sloppy_ranges: bool,
}
impl CronParser {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> CronParserBuilder {
CronParserBuilder::default()
}
pub fn parse(&self, pattern: &str) -> Result<Cron, CronError> {
let mut pattern: String = pattern.to_uppercase().trim().to_string();
if pattern.is_empty() {
return Err(CronError::EmptyPattern);
}
if pattern.contains('@') {
pattern = Self::handle_nicknames(
&pattern,
self.seconds.is_required(),
self.year.is_required(),
)
.to_string();
}
pattern = Self::replace_alpha_weekdays(&pattern, self.alternative_weekdays).to_string();
pattern = Self::replace_alpha_months(&pattern).to_string();
let mut parts: Vec<&str> = pattern.split_whitespace().collect();
let num_parts = parts.len();
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(),
));
}
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(),
));
}
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(),
));
}
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('?', "*");
}
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;
*dow_part = dow_part[1..].to_string();
}
}
parts = owned_parts.iter().map(|s| s.as_str()).collect();
self.throw_at_illegal_characters(&parts)?;
let star_dom = parts.get(3).is_some_and(|&p| p == "*");
let star_dow = parts.get(5).is_some_and(|&p| p == "*");
let mut seconds = CronComponent::new(0, 59, NONE_BIT, 0);
seconds.set_sloppy_ranges(self.sloppy_ranges);
seconds.parse(parts[0])?;
let mut minutes = CronComponent::new(0, 59, NONE_BIT, 0);
minutes.set_sloppy_ranges(self.sloppy_ranges);
minutes.parse(parts[1])?;
let mut hours = CronComponent::new(0, 23, NONE_BIT, 0);
hours.set_sloppy_ranges(self.sloppy_ranges);
hours.parse(parts[2])?;
let mut days = CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0);
days.set_sloppy_ranges(self.sloppy_ranges);
days.parse(parts[3])?;
let mut months = CronComponent::new(1, 12, NONE_BIT, 0);
months.set_sloppy_ranges(self.sloppy_ranges);
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.set_sloppy_ranges(self.sloppy_ranges);
days_of_week.parse(parts[5])?;
let mut years = CronComponent::new(
YEAR_LOWER_LIMIT as u16,
YEAR_UPPER_LIMIT as u16,
NONE_BIT,
0,
); years.set_sloppy_ranges(self.sloppy_ranges);
years.parse(parts[6])?;
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,
},
})
}
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() {
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(),
_ => 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(())
}
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
}
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();
for &(nickname, value) in &nicknames {
replaced = replaced.replace(nickname, value);
}
replaced
}
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();
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,
sloppy_ranges,
} = 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(),
sloppy_ranges: sloppy_ranges.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-59/5 * * * *").unwrap();
assert_eq!(cron.pattern.pattern, "5-59/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()); assert!(cron.pattern.months.is_bit_set(3, ALL_BIT).unwrap()); 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()); assert!(cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); }
#[test]
fn test_with_seconds_false() {
let parser = CronParser::builder().seconds(Seconds::Disallowed).build();
let error = parser.parse("* * * * * *").unwrap_err();
assert!(matches!(error, CronError::InvalidPattern(_)));
let no_seconds_pattern = parser.parse("*/10 * * * *").unwrap();
assert_eq!(no_seconds_pattern.to_string(), "*/10 * * * *");
assert!(no_seconds_pattern
.pattern
.seconds
.is_bit_set(0, ALL_BIT)
.unwrap());
}
#[test]
fn test_with_seconds_required() {
let no_seconds_pattern = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("*/10 * * * *")
.unwrap_err();
assert!(matches!(no_seconds_pattern, CronError::InvalidPattern(_)));
let cron = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("* * * * * *")
.unwrap();
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
}
#[test]
fn test_with_alternative_weekdays() {
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * MON-FRI")
.unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); }
#[test]
fn test_with_alternative_weekdays_numeric() {
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 2-6")
.unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); }
#[test]
fn test_seven_to_zero() {
let cron = Cron::from_str("* * * * 7").unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); }
#[test]
fn test_one_is_monday_alternative() {
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 1")
.unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); }
#[test]
fn test_zero_with_alternative_weekdays_fails() {
let error = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 0")
.unwrap_err();
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_nickname_aliases_case_insensitive() {
let nicknames = [
("@yearly", "@YEARLY", "@YeArLy"),
("@annually", "@ANNUALLY", "@AnNuAlLy"),
("@monthly", "@MONTHLY", "@MoNtHlY"),
("@weekly", "@WEEKLY", "@WeEkLy"),
("@daily", "@DAILY", "@DaIlY"),
("@hourly", "@HOURLY", "@HoUrLy"),
];
for (lower, upper, mixed) in nicknames {
let result_lower = Cron::from_str(lower);
let result_upper = Cron::from_str(upper);
let result_mixed = Cron::from_str(mixed);
assert!(result_lower.is_ok(), "Should parse lowercase {}", lower);
assert!(result_upper.is_ok(), "Should parse uppercase {}", upper);
assert!(result_mixed.is_ok(), "Should parse mixed case {}", mixed);
let cron_lower = result_lower.unwrap();
let cron_upper = result_upper.unwrap();
let cron_mixed = result_mixed.unwrap();
assert_eq!(
cron_lower.pattern, cron_upper.pattern,
"Lowercase and uppercase {} should produce the same pattern",
lower
);
assert_eq!(
cron_lower.pattern, cron_mixed.pattern,
"Lowercase and mixed case {} should produce the same pattern",
lower
);
}
}
#[test]
fn test_month_aliases_case_insensitive() {
let months = [
("JAN", "jan", "Jan"),
("FEB", "feb", "Feb"),
("MAR", "mar", "Mar"),
("APR", "apr", "Apr"),
("MAY", "may", "May"),
("JUN", "jun", "Jun"),
("JUL", "jul", "Jul"),
("AUG", "aug", "Aug"),
("SEP", "sep", "Sep"),
("OCT", "oct", "Oct"),
("NOV", "nov", "Nov"),
("DEC", "dec", "Dec"),
];
for (upper, lower, mixed) in months {
let pattern_upper = format!("0 0 1 {} *", upper);
let pattern_lower = format!("0 0 1 {} *", lower);
let pattern_mixed = format!("0 0 1 {} *", mixed);
let result_upper = Cron::from_str(&pattern_upper);
let result_lower = Cron::from_str(&pattern_lower);
let result_mixed = Cron::from_str(&pattern_mixed);
assert!(result_upper.is_ok(), "Should parse uppercase {}", upper);
assert!(result_lower.is_ok(), "Should parse lowercase {}", lower);
assert!(result_mixed.is_ok(), "Should parse mixed case {}", mixed);
let cron_upper = result_upper.unwrap();
let cron_lower = result_lower.unwrap();
let cron_mixed = result_mixed.unwrap();
assert_eq!(
cron_upper.pattern.months, cron_lower.pattern.months,
"Uppercase and lowercase {} should produce the same months",
upper
);
assert_eq!(
cron_upper.pattern.months, cron_mixed.pattern.months,
"Uppercase and mixed case {} should produce the same months",
upper
);
}
}
#[test]
fn test_weekday_aliases_case_insensitive() {
let weekdays = [
("SUN", "sun", "Sun"),
("MON", "mon", "Mon"),
("TUE", "tue", "Tue"),
("WED", "wed", "Wed"),
("THU", "thu", "Thu"),
("FRI", "fri", "Fri"),
("SAT", "sat", "Sat"),
];
for (upper, lower, mixed) in weekdays {
let pattern_upper = format!("0 0 * * {}", upper);
let pattern_lower = format!("0 0 * * {}", lower);
let pattern_mixed = format!("0 0 * * {}", mixed);
let result_upper = Cron::from_str(&pattern_upper);
let result_lower = Cron::from_str(&pattern_lower);
let result_mixed = Cron::from_str(&pattern_mixed);
assert!(result_upper.is_ok(), "Should parse uppercase {}", upper);
assert!(result_lower.is_ok(), "Should parse lowercase {}", lower);
assert!(result_mixed.is_ok(), "Should parse mixed case {}", mixed);
let cron_upper = result_upper.unwrap();
let cron_lower = result_lower.unwrap();
let cron_mixed = result_mixed.unwrap();
assert_eq!(
cron_upper.pattern.days_of_week, cron_lower.pattern.days_of_week,
"Uppercase and lowercase {} should produce the same days_of_week",
upper
);
assert_eq!(
cron_upper.pattern.days_of_week, cron_mixed.pattern.days_of_week,
"Uppercase and mixed case {} should produce the same days_of_week",
upper
);
}
}
#[test]
fn test_last_modifier_case_insensitive() {
let patterns = ["0 0 L * *", "0 0 l * *"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse L modifier pattern: {}",
patterns[i]
);
}
let cron_upper = results[0].as_ref().unwrap();
let cron_lower = results[1].as_ref().unwrap();
assert_eq!(
cron_upper.pattern.days, cron_lower.pattern.days,
"L and l modifiers should produce the same days"
);
}
#[test]
fn test_last_weekday_modifier_case_insensitive() {
let patterns = ["0 0 * * 5L", "0 0 * * 5l"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse weekday L modifier pattern: {}",
patterns[i]
);
}
let cron_upper = results[0].as_ref().unwrap();
let cron_lower = results[1].as_ref().unwrap();
assert_eq!(
cron_upper.pattern.days_of_week, cron_lower.pattern.days_of_week,
"5L and 5l modifiers should produce the same days_of_week"
);
}
#[test]
fn test_closest_weekday_modifier_case_insensitive() {
let patterns = ["0 0 15W * *", "0 0 15w * *"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse W modifier pattern: {}",
patterns[i]
);
}
let cron_upper = results[0].as_ref().unwrap();
let cron_lower = results[1].as_ref().unwrap();
assert_eq!(
cron_upper.pattern.days, cron_lower.pattern.days,
"15W and 15w modifiers should produce the same days"
);
}
#[test]
fn test_last_weekday_of_month_lw_case_insensitive() {
let patterns = ["0 0 LW * *", "0 0 lw * *", "0 0 Lw * *", "0 0 lW * *"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse LW modifier pattern: {}",
patterns[i]
);
}
let cron_first = results[0].as_ref().unwrap();
for (i, result) in results.iter().enumerate().skip(1) {
let cron = result.as_ref().unwrap();
assert_eq!(
cron_first.pattern.days, cron.pattern.days,
"LW pattern {} should produce the same days as LW",
patterns[i]
);
}
}
#[test]
fn test_weekday_alias_with_nth_modifier_case_insensitive() {
let patterns = ["0 0 * * MON#2", "0 0 * * mon#2", "0 0 * * Mon#2"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse weekday with # pattern: {}",
patterns[i]
);
}
let cron_first = results[0].as_ref().unwrap();
for (i, result) in results.iter().enumerate().skip(1) {
let cron = result.as_ref().unwrap();
assert_eq!(
cron_first.pattern.days_of_week, cron.pattern.days_of_week,
"Pattern {} should produce the same days_of_week as MON#2",
patterns[i]
);
}
}
#[test]
fn test_month_alias_range_case_insensitive() {
let patterns = ["0 0 * JAN-MAR *", "0 0 * jan-mar *", "0 0 * Jan-Mar *"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse month range pattern: {}",
patterns[i]
);
}
let cron_first = results[0].as_ref().unwrap();
for (i, result) in results.iter().enumerate().skip(1) {
let cron = result.as_ref().unwrap();
assert_eq!(
cron_first.pattern.months, cron.pattern.months,
"Pattern {} should produce the same months as JAN-MAR",
patterns[i]
);
}
}
#[test]
fn test_weekday_alias_range_case_insensitive() {
let patterns = ["0 0 * * MON-FRI", "0 0 * * mon-fri", "0 0 * * Mon-Fri"];
let results: Vec<_> = patterns.iter().map(|p| Cron::from_str(p)).collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"Should parse weekday range pattern: {}",
patterns[i]
);
}
let cron_first = results[0].as_ref().unwrap();
for (i, result) in results.iter().enumerate().skip(1) {
let cron = result.as_ref().unwrap();
assert_eq!(
cron_first.pattern.days_of_week, cron.pattern.days_of_week,
"Pattern {} should produce the same days_of_week as MON-FRI",
patterns[i]
);
}
}
#[test]
fn test_year_support() {
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Optional)
.build();
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
assert!(parser.parse("0 0 0 1 1 *").is_ok());
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();
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
assert!(parser.parse("0 0 0 1 1 *").is_err());
}
#[test]
fn test_optional_seconds_and_required_year_fails_on_six_parts() {
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Required)
.build();
let result = parser.parse("* * * * * *");
assert!(
matches!(result, Err(CronError::InvalidPattern(_))),
"Should fail when year is required but not provided."
);
}
}