const DAY_NAMES: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
fn shift_digit(digits: &str) -> String {
let Ok(value) = digits.parse::<u8>() else {
return digits.to_owned();
};
match value {
0 | 7 => "1".to_owned(),
1..=6 => (value + 1).to_string(),
_ => digits.to_owned(),
}
}
fn take_day_token(s: &str) -> Option<(&str, bool, &str)> {
let digit_len = s.bytes().take_while(u8::is_ascii_digit).count();
if digit_len > 0 {
let (digits, rest) = s.split_at(digit_len);
return Some((digits, true, rest));
}
let prefix = s.get(..3)?;
if DAY_NAMES.iter().any(|n| prefix.eq_ignore_ascii_case(n)) {
let (name, rest) = s.split_at(3);
return Some((name, false, rest));
}
None
}
fn normalize_dow_atom(atom: &str) -> String {
if atom == "*" || atom.starts_with("*/") || atom == "L" {
return atom.to_owned();
}
let Some((first, first_numeric, rest)) = take_day_token(atom) else {
return atom.to_owned();
};
let first_out = if first_numeric {
shift_digit(first)
} else {
first.to_owned()
};
if rest.is_empty() {
return first_out;
}
if rest == "L" || rest.starts_with('#') || rest.starts_with('/') {
return format!("{first_out}{rest}");
}
if let Some(after_dash) = rest.strip_prefix('-') {
let Some((second, second_numeric, range_rest)) = take_day_token(after_dash) else {
return atom.to_owned();
};
if range_rest.is_empty()
&& first_numeric
&& second_numeric
&& let (Ok(a), Ok(b)) = (first.parse::<u8>(), second.parse::<u8>())
{
let mut endpoints = [a, b];
endpoints.sort_unstable();
if endpoints == [0, 7] {
return "*".to_owned();
}
}
let second_out = if second_numeric {
shift_digit(second)
} else {
second.to_owned()
};
return format!("{first_out}-{second_out}{range_rest}");
}
atom.to_owned()
}
#[must_use]
pub(crate) fn normalize_cron_dow(expr: &str) -> String {
let fields: Vec<&str> = expr.split_ascii_whitespace().collect();
let [minute, hour, dom, month, dow] = fields[..] else {
return expr.to_owned();
};
let normalized_dow = dow
.split(',')
.map(normalize_dow_atom)
.collect::<Vec<_>>()
.join(",");
format!("{minute} {hour} {dom} {month} {normalized_dow}")
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::normalize_cron_dow;
struct Case {
name: &'static str,
input: &'static str,
expected: &'static str,
}
#[test]
fn normalizes_the_day_of_week_field_only() {
let cases = vec![
Case {
name: "weekday_range_shifts_up_by_one",
input: "30 11 * * 1-5",
expected: "30 11 * * 2-6",
},
Case {
name: "standard_sunday_zero_maps_to_saffron_sunday_one",
input: "0 9 * * 0",
expected: "0 9 * * 1",
},
Case {
name: "standard_sunday_seven_also_maps_to_saffron_sunday_one",
input: "0 9 * * 7",
expected: "0 9 * * 1",
},
Case {
name: "saturday_six_shifts_to_seven",
input: "* * * * 6",
expected: "* * * * 7",
},
Case {
name: "comma_list_shifts_each_value",
input: "0 0 * * 1,3,5",
expected: "0 0 * * 2,4,6",
},
Case {
name: "star_is_untouched",
input: "* * * * *",
expected: "* * * * *",
},
Case {
name: "star_step_is_untouched_since_it_names_no_specific_day",
input: "* * * * */2",
expected: "* * * * */2",
},
Case {
name: "range_step_shifts_the_day_endpoints_but_not_the_step_count",
input: "0 0 * * 1-5/2",
expected: "0 0 * * 2-6/2",
},
Case {
name: "bare_step_shifts_the_day_but_not_the_step_count",
input: "0 0 * * 1/2",
expected: "0 0 * * 2/2",
},
Case {
name: "wrapping_range_across_the_week_boundary_shifts_both_ends",
input: "0 0 * * 5-1",
expected: "0 0 * * 6-2",
},
Case {
name: "sunday_to_friday_range_shifts_both_ends",
input: "0 0 * * 0-5",
expected: "0 0 * * 1-6",
},
Case {
name: "three_letter_day_names_are_left_untouched",
input: "0 9 * * MON-FRI",
expected: "0 9 * * MON-FRI",
},
Case {
name: "lowercase_day_names_are_left_untouched",
input: "0 9 * * mon-fri",
expected: "0 9 * * mon-fri",
},
Case {
name: "mixed_numeric_and_name_range_shifts_only_the_numeric_end",
input: "0 9 * * 1-FRI",
expected: "0 9 * * 2-FRI",
},
Case {
name: "nth_weekday_modifier_shifts_the_day_but_not_the_nth_count",
input: "0 9 * * 1#3",
expected: "0 9 * * 2#3",
},
Case {
name: "last_weekday_modifier_shifts_the_day",
input: "0 9 * * 5L",
expected: "0 9 * * 6L",
},
Case {
name: "bare_last_day_marker_has_no_digit_to_shift",
input: "0 9 * * L",
expected: "0 9 * * L",
},
Case {
name: "out_of_range_digit_is_left_for_saffron_to_reject",
input: "0 9 * * 8",
expected: "0 9 * * 8",
},
Case {
name: "non_cron_text_with_the_wrong_field_count_is_left_unchanged",
input: "not a cron expression",
expected: "not a cron expression",
},
Case {
name: "other_fields_are_never_touched",
input: "7 6 5 4 1",
expected: "7 6 5 4 2",
},
Case {
name: "sunday_zero_to_seven_range_names_the_whole_week",
input: "0 9 * * 0-7",
expected: "0 9 * * *",
},
Case {
name: "sunday_seven_to_zero_range_also_names_the_whole_week",
input: "0 9 * * 7-0",
expected: "0 9 * * *",
},
];
for case in cases {
assert_eq!(
normalize_cron_dow(case.input),
case.expected,
"case {}",
case.name
);
}
}
#[test]
fn a_multi_byte_day_of_week_atom_does_not_panic_and_is_left_unchanged() {
let cases = [
("0 9 * * éé", "0 9 * * éé"),
("0 9 * * aaé", "0 9 * * aaé"),
("0 9 * * 1,éé", "0 9 * * 2,éé"),
];
for (input, expected) in cases {
let normalized = normalize_cron_dow(input);
assert_eq!(normalized, expected, "input {input}");
assert!(
normalized.parse::<saffron::Cron>().is_err(),
"expected `saffron` to still reject {input:?} (normalized to {normalized:?})"
);
}
}
}