mod civil;
pub mod expand;
pub mod set;
pub mod validate;
use core::{fmt, num::ParseIntError, ops::Range, str::FromStr};
use alloc::{string::String, vec::Vec};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct IcalRecurDateTime {
pub year: i32,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
impl IcalRecurDateTime {
pub const fn seconds(&self) -> i64 {
civil::days_from_civil(self.year, self.month, self.day) * 86_400
+ self.hour as i64 * 3600
+ self.minute as i64 * 60
+ self.second as i64
}
pub const fn from_seconds(seconds: i64) -> Self {
let days = seconds.div_euclid(86_400);
let rest = seconds.rem_euclid(86_400);
let (year, month, day) = civil::civil_from_days(days);
Self {
year,
month,
day,
hour: (rest / 3600) as u8,
minute: (rest % 3600 / 60) as u8,
second: (rest % 60) as u8,
}
}
pub const fn date(year: i32, month: u8, day: u8) -> Self {
Self {
year,
month,
day,
hour: 0,
minute: 0,
second: 0,
}
}
pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
let bytes = value.as_bytes();
let naive = match bytes.len() {
8 => value,
15 => value,
16 if bytes[15] == b'Z' || bytes[15] == b'z' => &value[..15],
_ => return Err(IcalRecurRuleError::DateTime),
};
if naive.len() == 15 && !matches!(naive.as_bytes()[8], b'T' | b't') {
return Err(IcalRecurRuleError::DateTime);
}
let num = |range: Range<usize>| -> Result<u32, IcalRecurRuleError> {
naive
.get(range)
.ok_or(IcalRecurRuleError::DateTime)?
.parse()
.map_err(|_| IcalRecurRuleError::DateTime)
};
let mut parsed = Self::date(num(0..4)? as i32, num(4..6)? as u8, num(6..8)? as u8);
if naive.len() == 15 {
parsed.hour = num(9..11)? as u8;
parsed.minute = num(11..13)? as u8;
parsed.second = num(13..15)? as u8;
}
let valid_date = (1..=12).contains(&parsed.month)
&& parsed.day >= 1
&& parsed.day <= civil::days_in_month(parsed.year, parsed.month);
let valid_time = parsed.hour < 24 && parsed.minute < 60 && parsed.second < 61;
if !valid_date || !valid_time {
return Err(IcalRecurRuleError::DateTime);
}
Ok(parsed)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcalRecurFreq {
Secondly,
Minutely,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}
impl FromStr for IcalRecurFreq {
type Err = IcalRecurRuleError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("SECONDLY") {
Ok(Self::Secondly)
} else if s.eq_ignore_ascii_case("MINUTELY") {
Ok(Self::Minutely)
} else if s.eq_ignore_ascii_case("HOURLY") {
Ok(Self::Hourly)
} else if s.eq_ignore_ascii_case("DAILY") {
Ok(Self::Daily)
} else if s.eq_ignore_ascii_case("WEEKLY") {
Ok(Self::Weekly)
} else if s.eq_ignore_ascii_case("MONTHLY") {
Ok(Self::Monthly)
} else if s.eq_ignore_ascii_case("YEARLY") {
Ok(Self::Yearly)
} else {
Err(IcalRecurRuleError::Freq)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum IcalRecurWeekday {
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
impl FromStr for IcalRecurWeekday {
type Err = IcalRecurRuleError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("SU") {
Ok(Self::Sunday)
} else if s.eq_ignore_ascii_case("MO") {
Ok(Self::Monday)
} else if s.eq_ignore_ascii_case("TU") {
Ok(Self::Tuesday)
} else if s.eq_ignore_ascii_case("WE") {
Ok(Self::Wednesday)
} else if s.eq_ignore_ascii_case("TH") {
Ok(Self::Thursday)
} else if s.eq_ignore_ascii_case("FR") {
Ok(Self::Friday)
} else if s.eq_ignore_ascii_case("SA") {
Ok(Self::Saturday)
} else {
Err(IcalRecurRuleError::Weekday)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IcalRecurWeekdayNum {
pub ordinal: Option<i16>,
pub weekday: IcalRecurWeekday,
}
impl FromStr for IcalRecurWeekdayNum {
type Err = IcalRecurRuleError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let split = s.char_indices().rev().nth(1).map_or(0, |(index, _)| index);
let (ordinal, weekday) = s.split_at(split);
let weekday = weekday.parse()?;
let ordinal = match ordinal {
"" => None,
ordinal => Some(ordinal.parse().map_err(IcalRecurRuleError::Ordinal)?),
};
Ok(Self { ordinal, weekday })
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IcalRecurSkip {
#[default]
Omit,
Backward,
Forward,
}
impl FromStr for IcalRecurSkip {
type Err = IcalRecurRuleError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("OMIT") {
Ok(Self::Omit)
} else if s.eq_ignore_ascii_case("BACKWARD") {
Ok(Self::Backward)
} else if s.eq_ignore_ascii_case("FORWARD") {
Ok(Self::Forward)
} else {
Err(IcalRecurRuleError::Skip)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IcalRecurRule {
pub freq: IcalRecurFreq,
pub until: Option<IcalRecurDateTime>,
pub count: Option<u32>,
pub interval: u32,
pub by_second: Vec<u8>,
pub by_minute: Vec<u8>,
pub by_hour: Vec<u8>,
pub by_day: Vec<IcalRecurWeekdayNum>,
pub by_month_day: Vec<i8>,
pub by_year_day: Vec<i16>,
pub by_week_no: Vec<i8>,
pub by_month: Vec<u8>,
pub by_set_pos: Vec<i16>,
pub week_start: IcalRecurWeekday,
pub scale: Option<String>,
pub skip: IcalRecurSkip,
}
impl IcalRecurRule {
pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
let mut freq = None;
let mut rule = Self {
freq: IcalRecurFreq::Daily,
until: None,
count: None,
interval: 1,
by_second: Vec::new(),
by_minute: Vec::new(),
by_hour: Vec::new(),
by_day: Vec::new(),
by_month_day: Vec::new(),
by_year_day: Vec::new(),
by_week_no: Vec::new(),
by_month: Vec::new(),
by_set_pos: Vec::new(),
week_start: IcalRecurWeekday::Monday,
scale: None,
skip: IcalRecurSkip::Omit,
};
for part in value.split(';').filter(|part| !part.is_empty()) {
let Some((name, raw)) = part.split_once('=') else {
continue;
};
let name = name.trim();
let raw = raw.trim();
if name.eq_ignore_ascii_case("FREQ") {
freq = Some(raw.parse()?);
} else if name.eq_ignore_ascii_case("UNTIL") {
rule.until = Some(IcalRecurDateTime::parse(raw)?);
} else if name.eq_ignore_ascii_case("COUNT") {
rule.count = Some(raw.parse().map_err(IcalRecurRuleError::Count)?);
} else if name.eq_ignore_ascii_case("INTERVAL") {
let interval = raw.parse().map_err(IcalRecurRuleError::Interval)?;
if interval == 0 {
return Err(IcalRecurRuleError::IntervalZero);
}
rule.interval = interval;
} else if name.eq_ignore_ascii_case("BYSECOND") {
rule.by_second = numbers(raw, 0, 60)?;
} else if name.eq_ignore_ascii_case("BYMINUTE") {
rule.by_minute = numbers(raw, 0, 59)?;
} else if name.eq_ignore_ascii_case("BYHOUR") {
rule.by_hour = numbers(raw, 0, 23)?;
} else if name.eq_ignore_ascii_case("BYDAY") {
rule.by_day = weekday_nums(raw)?;
} else if name.eq_ignore_ascii_case("BYMONTHDAY") {
rule.by_month_day = signed(raw, 1, 31)?;
} else if name.eq_ignore_ascii_case("BYYEARDAY") {
rule.by_year_day = signed(raw, 1, 366)?;
} else if name.eq_ignore_ascii_case("BYWEEKNO") {
rule.by_week_no = signed(raw, 1, 53)?;
} else if name.eq_ignore_ascii_case("BYMONTH") {
rule.by_month = numbers(raw, 1, 12)?;
} else if name.eq_ignore_ascii_case("BYSETPOS") {
rule.by_set_pos = signed(raw, 1, 366)?;
} else if name.eq_ignore_ascii_case("WKST") {
rule.week_start = raw.parse()?;
} else if name.eq_ignore_ascii_case("RSCALE") {
rule.scale = Some(raw.to_ascii_uppercase());
} else if name.eq_ignore_ascii_case("SKIP") {
rule.skip = raw.parse()?;
}
}
rule.freq = freq.ok_or(IcalRecurRuleError::FreqMissing)?;
Ok(rule)
}
}
fn numbers<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
where
T: TryFrom<i32>,
{
let mut parsed = Vec::new();
for item in raw.split(',').filter(|item| !item.is_empty()) {
let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
if value < min || value > max {
return Err(IcalRecurRuleError::Range);
}
parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
}
Ok(parsed)
}
fn signed<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
where
T: TryFrom<i32>,
{
let mut parsed = Vec::new();
for item in raw.split(',').filter(|item| !item.is_empty()) {
let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
let magnitude = value.unsigned_abs() as i32;
if value == 0 || magnitude < min || magnitude > max {
return Err(IcalRecurRuleError::Range);
}
parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
}
Ok(parsed)
}
fn weekday_nums(raw: &str) -> Result<Vec<IcalRecurWeekdayNum>, IcalRecurRuleError> {
let mut parsed = Vec::new();
for item in raw.split(',').filter(|item| !item.is_empty()) {
parsed.push(item.trim().parse()?);
}
Ok(parsed)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IcalRecurRuleError {
Freq,
FreqMissing,
Weekday,
Skip,
DateTime,
Count(ParseIntError),
Interval(ParseIntError),
IntervalZero,
Ordinal(ParseIntError),
Number(ParseIntError),
Range,
}
impl fmt::Display for IcalRecurRuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Freq => write!(f, "Unknown recurrence frequency"),
Self::FreqMissing => write!(f, "Missing recurrence frequency"),
Self::Weekday => write!(f, "Unknown recurrence weekday"),
Self::Skip => write!(f, "Unknown recurrence skip"),
Self::DateTime => write!(f, "Invalid recurrence date or date-time"),
Self::Count(err) => write!(f, "Invalid recurrence count: {err}"),
Self::Interval(err) => write!(f, "Invalid recurrence interval: {err}"),
Self::IntervalZero => write!(f, "Recurrence interval cannot be zero"),
Self::Ordinal(err) => write!(f, "Invalid recurrence weekday ordinal: {err}"),
Self::Number(err) => write!(f, "Invalid recurrence number: {err}"),
Self::Range => write!(f, "Recurrence number out of range"),
}
}
}
#[cfg(test)]
mod tests {
use alloc::{format, vec};
use crate::recur::*;
#[test]
fn parses_every_part() {
let rule = IcalRecurRule::parse(
"FREQ=YEARLY;INTERVAL=2;BYMONTH=1,3;BYDAY=-1SU,MO;BYMONTHDAY=1,-1;\
BYYEARDAY=100,-1;BYWEEKNO=1,-1;BYHOUR=9;BYMINUTE=30;BYSECOND=0;\
BYSETPOS=-1;WKST=SU;UNTIL=20301231T235959Z",
)
.unwrap();
assert_eq!(rule.freq, IcalRecurFreq::Yearly);
assert_eq!(rule.interval, 2);
assert_eq!(rule.by_month, vec![1, 3]);
assert_eq!(rule.by_day[0].ordinal, Some(-1));
assert_eq!(rule.by_day[0].weekday, IcalRecurWeekday::Sunday);
assert_eq!(rule.by_day[1].ordinal, None);
assert_eq!(rule.by_month_day, vec![1, -1]);
assert_eq!(rule.by_year_day, vec![100, -1]);
assert_eq!(rule.by_week_no, vec![1, -1]);
assert_eq!(rule.by_set_pos, vec![-1]);
assert_eq!(rule.week_start, IcalRecurWeekday::Sunday);
assert_eq!(
rule.until,
Some(IcalRecurDateTime {
year: 2030,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 59,
})
);
}
#[test]
fn defaults_interval_and_week_start() {
let rule = IcalRecurRule::parse("FREQ=DAILY").unwrap();
assert_eq!(rule.interval, 1);
assert_eq!(rule.week_start, IcalRecurWeekday::Monday);
assert_eq!(rule.skip, IcalRecurSkip::Omit);
}
#[test]
fn ignores_unknown_parts() {
let rule = IcalRecurRule::parse("FREQ=DAILY;X-VENDOR=1;NONSENSE=abc").unwrap();
assert_eq!(rule.freq, IcalRecurFreq::Daily);
}
#[test]
fn refuses_contradictions() {
assert_eq!(
IcalRecurRule::parse("INTERVAL=2"),
Err(IcalRecurRuleError::FreqMissing)
);
assert_eq!(
IcalRecurRule::parse("FREQ=DAILY;INTERVAL=0"),
Err(IcalRecurRuleError::IntervalZero)
);
assert_eq!(
IcalRecurRule::parse("FREQ=FORTNIGHTLY"),
Err(IcalRecurRuleError::Freq)
);
}
#[test]
fn refuses_out_of_range_and_zero_ordinals() {
assert_eq!(
IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=32"),
Err(IcalRecurRuleError::Range)
);
assert_eq!(
IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=0"),
Err(IcalRecurRuleError::Range)
);
assert_eq!(
IcalRecurRule::parse("FREQ=YEARLY;BYMONTH=13"),
Err(IcalRecurRuleError::Range)
);
}
#[test]
fn refuses_a_multi_byte_weekday_rather_than_splitting_it() {
for value in ["€", "𝄞", "SU€", "-1€", "1FR€"] {
assert!(IcalRecurRule::parse(&format!("FREQ=DAILY;BYDAY={value}")).is_err());
}
}
#[test]
fn parses_the_three_until_spellings() {
let date = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102").unwrap();
assert_eq!(date.until, Some(IcalRecurDateTime::date(2030, 1, 2)));
let local = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405").unwrap();
let utc = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405Z").unwrap();
assert_eq!(local.until, utc.until);
assert_eq!(local.until.unwrap().hour, 3);
assert_eq!(
IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300230"),
Err(IcalRecurRuleError::DateTime)
);
}
}