use std::ops::RangeInclusive;
use crate::core::DateTime;
use crate::{Frequency, NWeekday, RRule, Tz, Unvalidated};
use super::ValidationError;
pub(crate) static MONTH_RANGE: RangeInclusive<u8> = 1..=12;
pub(crate) static YEAR_RANGE: RangeInclusive<i32> = -10_000..=10_000;
type Validator = &'static dyn Fn(&RRule<Unvalidated>, &DateTime) -> Result<(), ValidationError>;
const VALIDATION_PIPELINE: [Validator; 11] = [
&validate_until,
&validate_by_set_pos,
&validate_by_month,
&validate_by_month_day,
&validate_by_year_day,
&validate_by_week_number,
&validate_by_weekday,
&validate_by_hour,
&validate_by_minute,
&validate_by_second,
&validate_by_easter,
];
pub(crate) fn validate_rrule_forced(
rrule: &RRule<Unvalidated>,
dt_start: &DateTime,
) -> Result<(), ValidationError> {
VALIDATION_PIPELINE
.into_iter()
.try_for_each(|validator| validator(rrule, dt_start))
}
fn validate_until(rrule: &RRule<Unvalidated>, dt_start: &DateTime) -> Result<(), ValidationError> {
match rrule.until {
Some(until) => {
match dt_start.timezone() {
Tz::Local(_) => {
let allowed_timezones = vec![Tz::LOCAL, Tz::UTC];
if !allowed_timezones.contains(&until.timezone()) {
return Err(ValidationError::DtStartUntilMismatchTimezone {
dt_start_tz: dt_start.timezone().name().into(),
until_tz: until.timezone().name().into(),
expected: allowed_timezones
.into_iter()
.map(|tz| tz.name().into())
.collect(),
});
}
}
Tz::Tz(_) => {
if until.timezone() != Tz::UTC {
return Err(ValidationError::DtStartUntilMismatchTimezone {
dt_start_tz: dt_start.timezone().name().into(),
until_tz: until.timezone().name().into(),
expected: vec!["UTC".into()],
});
}
}
}
if until < *dt_start {
return Err(ValidationError::UntilBeforeStart {
until: until.to_rfc3339(),
dt_start: dt_start.to_rfc3339(),
});
}
Ok(())
}
_ => Ok(()),
}
}
fn validate_by_set_pos(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_not_equal_for_vec(&0, &rrule.by_set_pos, "BYSETPOS")?;
let range = match rrule.freq {
Frequency::Yearly | Frequency::Daily => -366..=366, Frequency::Monthly => -31..=31,
Frequency::Weekly => -53..=53,
Frequency::Hourly => -24..=24,
Frequency::Minutely | Frequency::Secondly => -60..=60,
};
if let Err(value) = validate_range_for_vec_error(&range, &rrule.by_set_pos) {
return Err(ValidationError::InvalidFieldValueRangeWithFreq {
field: "BYSETPOS".into(),
value: value.to_string(),
freq: rrule.freq,
start_idx: range.start().to_string(),
end_idx: range.end().to_string(),
});
}
if !rrule.by_set_pos.is_empty()
&& rrule.by_easter.is_none()
&& rrule.by_hour.is_empty()
&& rrule.by_minute.is_empty()
&& rrule.by_second.is_empty()
&& rrule.by_month_day.is_empty()
&& rrule.by_month.is_empty()
&& rrule.by_year_day.is_empty()
&& rrule.by_week_no.is_empty()
&& rrule.by_weekday.is_empty()
{
return Err(ValidationError::BySetPosWithoutByRule);
}
Ok(())
}
fn validate_by_month(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_range_for_vec(&MONTH_RANGE, &rrule.by_month, "BYMONTH")
}
fn validate_by_month_day(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_not_equal_for_vec(&0, &rrule.by_month_day, "BYMONTHDAY")?;
validate_range_for_vec(&(-31..=31), &rrule.by_month_day, "BYMONTHDAY")?;
if !rrule.by_month_day.is_empty() {
let valid = rrule.freq != Frequency::Weekly;
if !valid {
return Err(ValidationError::InvalidByRuleAndFrequency {
by_rule: "BYMONTHDAY".into(),
freq: rrule.freq,
});
}
}
Ok(())
}
fn validate_by_year_day(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_not_equal_for_vec(&0, &rrule.by_year_day, "BYYEARDAY")?;
validate_range_for_vec(&(-366..=366), &rrule.by_year_day, "BYYEARDAY")?;
if !rrule.by_year_day.is_empty() {
let valid = !matches!(
rrule.freq,
Frequency::Monthly | Frequency::Weekly | Frequency::Daily
);
if !valid {
return Err(ValidationError::InvalidByRuleAndFrequency {
by_rule: "BYYEARDAY".into(),
freq: rrule.freq,
});
}
}
Ok(())
}
fn validate_by_week_number(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_not_equal_for_vec(&0, &rrule.by_week_no, "BYWEEKNO")?;
validate_range_for_vec(&(-53..=53), &rrule.by_week_no, "BYWEEKNO")?;
if !rrule.by_week_no.is_empty() {
let valid = rrule.freq == Frequency::Yearly;
if !valid {
return Err(ValidationError::InvalidByRuleAndFrequency {
by_rule: "BYWEEKNO".into(),
freq: rrule.freq,
});
}
}
Ok(())
}
fn validate_by_weekday(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
let range = match rrule.freq {
Frequency::Yearly | Frequency::Daily => (-366 / 7)..=(366 / 7 + 1), Frequency::Monthly => (-31 / 7)..=(31 / 7 + 1),
Frequency::Weekly => (-53 / 7)..=(53 / 7 + 1),
Frequency::Hourly => (-24 / 7)..=(24 / 7 + 1),
Frequency::Minutely | Frequency::Secondly => (-60 / 7)..=(60 / 7 + 1),
};
for item in &rrule.by_weekday {
if let NWeekday::Nth(number, _weekday) = item {
if !range.contains(number) {
return Err(ValidationError::InvalidFieldValueRangeWithFreq {
field: "BYDAY".into(),
value: number.to_string(),
freq: rrule.freq,
start_idx: range.start().to_string(),
end_idx: range.end().to_string(),
});
}
}
}
Ok(())
}
fn validate_by_hour(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_range_for_vec(&(0..=23), &rrule.by_hour, "BYHOUR")
}
fn validate_by_minute(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_range_for_vec(&(0..=59), &rrule.by_minute, "BYMINUTE")
}
fn validate_by_second(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
validate_range_for_vec(&(0..=59), &rrule.by_second, "BYSECOND")
}
fn validate_by_easter(
rrule: &RRule<Unvalidated>,
_dt_start: &DateTime,
) -> Result<(), ValidationError> {
#[cfg(feature = "by-easter")]
{
if let Some(by_easter) = &rrule.by_easter {
validate_range_for_vec(&(-366..=366), &[*by_easter], "BYEASTER")?;
}
if rrule.by_easter.is_some() {
let valid = matches!(
rrule.freq,
Frequency::Yearly | Frequency::Monthly | Frequency::Daily
);
if !valid {
return Err(ValidationError::InvalidByRuleAndFrequency {
by_rule: "BYEASTER".into(),
freq: rrule.freq,
});
}
}
if rrule.by_easter.is_some()
&& (rrule.by_hour.is_empty()
|| rrule.by_minute.is_empty()
|| rrule.by_second.is_empty())
{
return Err(ValidationError::InvalidByRuleWithByEaster);
}
}
#[cfg(not(feature = "by-easter"))]
{
if rrule.by_easter.is_some() {
log::warn!(
"The `by-easter` feature flag is not set, but `by_easter` is used.\
The `by_easter` will be ignored, as if it was set to `None`."
);
}
}
Ok(())
}
fn validate_range_for_vec_error<'a, T: PartialOrd>(
range: &RangeInclusive<T>,
list: &'a [T],
) -> Result<(), &'a T> {
for item in list {
if !range.contains(item) {
return Err(item);
}
}
Ok(())
}
fn validate_range_for_vec<T: PartialOrd + ToString>(
range: &RangeInclusive<T>,
list: &[T],
field: &str,
) -> Result<(), ValidationError> {
for item in list {
if !range.contains(item) {
return Err(ValidationError::InvalidFieldValueRange {
field: field.to_string(),
value: item.to_string(),
start_idx: range.start().to_string(),
end_idx: range.end().to_string(),
});
}
}
Ok(())
}
fn validate_not_equal_for_vec<T: PartialEq<T> + ToString>(
value: &T,
list: &[T],
field: &str,
) -> Result<(), ValidationError> {
if list.iter().any(|item| item == value) {
return Err(ValidationError::InvalidFieldValue {
field: field.to_string(),
value: value.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use crate::core::Tz;
use super::*;
const UTC: Tz = Tz::UTC;
#[test]
fn rejects_by_set_pos_without_byxxx_rule() {
let rrule = RRule {
by_set_pos: vec![-1],
..Default::default()
};
let dt_start = UTC.ymd(1970, 1, 1).and_hms(0, 0, 0);
let res = validate_rrule_forced(&rrule, &dt_start);
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(err, ValidationError::BySetPosWithoutByRule);
let res = rrule.build(dt_start);
assert!(res.is_ok());
}
#[test]
fn rejects_by_rule_field_with_invalid_value() {
let tests = [
(
"BYSETPOS",
RRule {
by_set_pos: vec![0],
..Default::default()
},
),
(
"BYSETPOS",
RRule {
by_set_pos: vec![1, -2, 0],
..Default::default()
},
),
(
"BYMONTHDAY",
RRule {
by_month_day: vec![0],
..Default::default()
},
),
(
"BYYEARDAY",
RRule {
by_year_day: vec![0],
..Default::default()
},
),
];
for (field, rrule) in tests {
let res = validate_rrule_forced(&rrule, &UTC.ymd(1970, 1, 1).and_hms(0, 0, 0));
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
ValidationError::InvalidFieldValue {
field: field.into(),
value: "0".into()
}
);
}
}
#[test]
fn rejects_by_rule_field_with_value_outside_allowed_range() {
let tests = [
(
"BYMONTHDAY",
RRule {
by_month_day: vec![34],
..Default::default()
},
"34",
"-31",
"31",
),
(
"BYYEARDAY",
RRule {
by_year_day: vec![17, 400],
..Default::default()
},
"400",
"-366",
"366",
),
];
for (field, rrule, value, start_idx, end_idx) in tests {
let res = validate_rrule_forced(&rrule, &UTC.ymd(1970, 1, 1).and_hms(0, 0, 0));
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
ValidationError::InvalidFieldValueRange {
field: field.into(),
value: value.into(),
start_idx: start_idx.into(),
end_idx: end_idx.into()
}
);
}
}
#[test]
fn rejects_by_rule_value_outside_allowed_freq_range() {
let tests = [
(
"BYSETPOS",
RRule {
freq: Frequency::Hourly,
by_set_pos: vec![30],
..Default::default()
},
"30",
"-24",
"24",
),
(
"BYSETPOS",
RRule {
freq: Frequency::Yearly,
by_set_pos: vec![400],
..Default::default()
},
"400",
"-366",
"366",
),
];
for (field, rrule, value, start_idx, end_idx) in tests {
let res = validate_rrule_forced(&rrule, &UTC.ymd(1970, 1, 1).and_hms(0, 0, 0));
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
ValidationError::InvalidFieldValueRangeWithFreq {
field: field.into(),
freq: rrule.freq,
value: value.into(),
start_idx: start_idx.into(),
end_idx: end_idx.into(),
}
);
}
}
#[test]
fn rejects_invalid_by_rule_and_freq_combos() {
let tests = [
(
"BYMONTHDAY",
RRule {
freq: Frequency::Weekly,
by_month_day: vec![-1],
..Default::default()
},
),
(
"BYYEARDAY",
RRule {
freq: Frequency::Monthly,
by_year_day: vec![120],
..Default::default()
},
),
(
"BYYEARDAY",
RRule {
freq: Frequency::Weekly,
by_year_day: vec![120],
..Default::default()
},
),
(
"BYYEARDAY",
RRule {
freq: Frequency::Daily,
by_year_day: vec![120],
..Default::default()
},
),
];
for (field, rrule) in tests {
let res = validate_rrule_forced(&rrule, &UTC.ymd(1970, 1, 1).and_hms(0, 0, 0));
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
ValidationError::InvalidByRuleAndFrequency {
by_rule: field.into(),
freq: rrule.freq
}
);
}
}
#[test]
fn rejects_start_date_after_until() {
let rrule = RRule {
until: Some(UTC.ymd_opt(2020, 1, 1).and_hms_opt(0, 0, 0).unwrap()),
..Default::default()
};
let dt_start = UTC.ymd_opt(2020, 1, 2).and_hms_opt(0, 0, 0).unwrap();
let res = validate_rrule_forced(&rrule, &dt_start);
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
ValidationError::UntilBeforeStart {
until: rrule.until.unwrap().to_rfc3339(),
dt_start: dt_start.to_rfc3339()
}
);
}
#[test]
fn allows_until_with_compatible_timezone() {
fn t(start_tz: Tz, until_tz: Tz) -> (DateTime, DateTime) {
(
start_tz
.ymd_opt(2020, 1, 1)
.and_hms_opt(0, 0, 0)
.unwrap()
.into(),
until_tz
.ymd_opt(2020, 1, 1)
.and_hms_opt(0, 0, 0)
.unwrap()
.into(),
)
}
let tests = [t(Tz::LOCAL, Tz::LOCAL), t(Tz::LOCAL, UTC), t(UTC, UTC)];
for (start_date, until) in tests {
let rrule = RRule {
until: Some(until),
..Default::default()
};
let res = validate_rrule_forced(&rrule, &start_date);
assert!(res.is_ok());
}
}
#[test]
fn rejects_until_with_incompatible_timezone() {
fn t(start_tz: Tz, until_tz: Tz) -> (DateTime, DateTime) {
(
start_tz
.ymd_opt(2020, 1, 1)
.and_hms_opt(0, 0, 0)
.unwrap()
.into(),
until_tz
.ymd_opt(2020, 1, 1)
.and_hms_opt(0, 0, 0)
.unwrap()
.into(),
)
}
let tests = [
t(Tz::UTC, Tz::LOCAL),
t(Tz::Europe__Berlin, Tz::LOCAL),
t(Tz::LOCAL, Tz::Europe__Berlin),
];
for (start_date, until) in tests {
let rrule = RRule {
until: Some(until),
..Default::default()
};
let res = validate_rrule_forced(&rrule, &start_date);
assert!(res.is_err());
let err = res.unwrap_err();
assert!(matches!(
err,
ValidationError::DtStartUntilMismatchTimezone { .. }
));
}
}
}