use core::{error, fmt};
use alloc::vec::Vec;
use crate::{
recur::{IcalRecurFreq, IcalRecurRule, IcalRecurSkip},
validator::IcalValid,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcalRecurPart {
BySecond,
ByMinute,
ByHour,
ByDay,
ByMonthDay,
ByYearDay,
ByWeekNo,
ByMonth,
BySetPos,
}
impl fmt::Display for IcalRecurPart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::BySecond => "BYSECOND",
Self::ByMinute => "BYMINUTE",
Self::ByHour => "BYHOUR",
Self::ByDay => "BYDAY",
Self::ByMonthDay => "BYMONTHDAY",
Self::ByYearDay => "BYYEARDAY",
Self::ByWeekNo => "BYWEEKNO",
Self::ByMonth => "BYMONTH",
Self::BySetPos => "BYSETPOS",
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcalRecurRuleProblem {
PartFreq {
part: IcalRecurPart,
freq: IcalRecurFreq,
},
OrdinalFreq {
freq: IcalRecurFreq,
},
OrdinalWithWeekNo,
SetPosAlone,
UntilWithCount,
SkipWithoutScale,
}
impl fmt::Display for IcalRecurRuleProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PartFreq { part, freq } => {
write!(f, "{part} may not be used with FREQ={}", freq_name(*freq))
}
Self::OrdinalFreq { freq } => {
write!(
f,
"a BYDAY ordinal may not be used with FREQ={}",
freq_name(*freq)
)
}
Self::OrdinalWithWeekNo => {
f.write_str("a BYDAY ordinal may not be used with FREQ=YEARLY beside BYWEEKNO")
}
Self::SetPosAlone => f.write_str("BYSETPOS needs another BY part to pick from"),
Self::UntilWithCount => f.write_str("UNTIL and COUNT may not both be given"),
Self::SkipWithoutScale => f.write_str("SKIP may not be used without RSCALE"),
}
}
}
impl error::Error for IcalRecurRuleProblem {}
fn freq_name(freq: IcalRecurFreq) -> &'static str {
match freq {
IcalRecurFreq::Secondly => "SECONDLY",
IcalRecurFreq::Minutely => "MINUTELY",
IcalRecurFreq::Hourly => "HOURLY",
IcalRecurFreq::Daily => "DAILY",
IcalRecurFreq::Weekly => "WEEKLY",
IcalRecurFreq::Monthly => "MONTHLY",
IcalRecurFreq::Yearly => "YEARLY",
}
}
impl IcalRecurRule {
pub fn validate(self) -> Result<IcalValid<Self>, Vec<IcalRecurRuleProblem>> {
let problems = self.problems();
if problems.is_empty() {
Ok(IcalValid(self))
} else {
Err(problems)
}
}
pub fn problems(&self) -> Vec<IcalRecurRuleProblem> {
use IcalRecurFreq::*;
use IcalRecurPart::*;
let mut problems = Vec::new();
let freq = self.freq;
if !self.by_week_no.is_empty() && freq != Yearly {
problems.push(IcalRecurRuleProblem::PartFreq {
part: ByWeekNo,
freq,
});
}
if !self.by_year_day.is_empty() && matches!(freq, Daily | Weekly | Monthly) {
problems.push(IcalRecurRuleProblem::PartFreq {
part: ByYearDay,
freq,
});
}
if !self.by_month_day.is_empty() && freq == Weekly {
problems.push(IcalRecurRuleProblem::PartFreq {
part: ByMonthDay,
freq,
});
}
let ordinal = self.by_day.iter().any(|day| day.ordinal.is_some());
if ordinal {
if !matches!(freq, Monthly | Yearly) {
problems.push(IcalRecurRuleProblem::OrdinalFreq { freq });
} else if freq == Yearly && !self.by_week_no.is_empty() {
problems.push(IcalRecurRuleProblem::OrdinalWithWeekNo);
}
}
if !self.by_set_pos.is_empty() && !self.has_other_by_part() {
problems.push(IcalRecurRuleProblem::SetPosAlone);
}
if self.until.is_some() && self.count.is_some() {
problems.push(IcalRecurRuleProblem::UntilWithCount);
}
if self.skip != IcalRecurSkip::Omit && self.scale.is_none() {
problems.push(IcalRecurRuleProblem::SkipWithoutScale);
}
problems
}
fn has_other_by_part(&self) -> bool {
!self.by_second.is_empty()
|| !self.by_minute.is_empty()
|| !self.by_hour.is_empty()
|| !self.by_day.is_empty()
|| !self.by_month_day.is_empty()
|| !self.by_year_day.is_empty()
|| !self.by_week_no.is_empty()
|| !self.by_month.is_empty()
}
}
#[cfg(test)]
mod tests {
use alloc::vec;
use crate::recur::{
IcalRecurDateTime, IcalRecurFreq, IcalRecurRule,
validate::{IcalRecurPart, IcalRecurRuleProblem},
};
fn problems(rule: &str) -> vec::Vec<IcalRecurRuleProblem> {
IcalRecurRule::parse(rule)
.expect("a readable rule")
.problems()
}
#[test]
fn accepts_the_rules_the_rfc_writes() {
for rule in [
"FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO",
"FREQ=MONTHLY;BYDAY=2MO",
"FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
"FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
"FREQ=SECONDLY;BYYEARDAY=1",
"FREQ=DAILY;COUNT=10",
] {
assert!(problems(rule).is_empty(), "{rule} should validate");
}
}
#[test]
fn reports_a_part_the_frequency_forbids() {
assert_eq!(
problems("FREQ=MONTHLY;BYWEEKNO=3"),
[IcalRecurRuleProblem::PartFreq {
part: IcalRecurPart::ByWeekNo,
freq: IcalRecurFreq::Monthly,
}]
);
assert_eq!(
problems("FREQ=WEEKLY;BYYEARDAY=100"),
[IcalRecurRuleProblem::PartFreq {
part: IcalRecurPart::ByYearDay,
freq: IcalRecurFreq::Weekly,
}]
);
assert_eq!(
problems("FREQ=WEEKLY;BYMONTHDAY=15"),
[IcalRecurRuleProblem::PartFreq {
part: IcalRecurPart::ByMonthDay,
freq: IcalRecurFreq::Weekly,
}]
);
}
#[test]
fn reports_an_ordinal_where_it_means_nothing() {
assert_eq!(
problems("FREQ=WEEKLY;BYDAY=2MO"),
[IcalRecurRuleProblem::OrdinalFreq {
freq: IcalRecurFreq::Weekly,
}]
);
assert_eq!(
problems("FREQ=YEARLY;BYWEEKNO=20;BYDAY=2MO"),
[IcalRecurRuleProblem::OrdinalWithWeekNo]
);
}
#[test]
fn reports_a_setpos_with_nothing_to_pick_from() {
assert_eq!(
problems("FREQ=DAILY;BYSETPOS=2"),
[IcalRecurRuleProblem::SetPosAlone]
);
assert!(problems("FREQ=DAILY;BYHOUR=9,17;BYSETPOS=2").is_empty());
}
#[test]
fn reports_a_rule_bounded_twice() {
let mut rule = IcalRecurRule::parse("FREQ=DAILY;COUNT=3").unwrap();
rule.until = Some(IcalRecurDateTime::date(2026, 1, 1));
assert_eq!(rule.problems(), [IcalRecurRuleProblem::UntilWithCount]);
}
#[test]
fn a_valid_rule_mints_a_proof() {
let rule = IcalRecurRule::parse("FREQ=MONTHLY;BYDAY=2MO").unwrap();
let valid = rule.validate().expect("a conformant rule");
assert_eq!(valid.freq, IcalRecurFreq::Monthly);
}
#[test]
fn expansion_stays_liberal_about_what_validation_reports() {
use crate::recur::{IcalRecurDateTime, expand::IcalRecurExpand};
let rule = IcalRecurRule::parse("FREQ=MONTHLY;BYWEEKNO=3").unwrap();
assert!(!rule.problems().is_empty());
let start = IcalRecurDateTime::date(2026, 1, 15);
let occurrences: vec::Vec<_> = IcalRecurExpand::new(rule, start).take(2).collect();
assert_eq!(occurrences, [start, IcalRecurDateTime::date(2026, 2, 15)]);
}
}