1use alloc::vec::Vec;
37use core::{fmt, num::ParseIntError, str::FromStr};
38
39mod civil;
40pub mod expand;
41pub mod set;
42pub mod validate;
43
44pub use self::validate::{IcalRecurPart, IcalRecurRuleProblem};
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
53pub struct IcalRecurDateTime {
54 pub year: i32,
56 pub month: u8,
58 pub day: u8,
60 pub hour: u8,
62 pub minute: u8,
64 pub second: u8,
66}
67
68impl IcalRecurDateTime {
69 pub const fn seconds(&self) -> i64 {
75 civil::days_from_civil(self.year, self.month, self.day) * 86_400
76 + self.hour as i64 * 3600
77 + self.minute as i64 * 60
78 + self.second as i64
79 }
80
81 pub const fn from_seconds(seconds: i64) -> Self {
84 let days = seconds.div_euclid(86_400);
85 let rest = seconds.rem_euclid(86_400);
86 let (year, month, day) = civil::civil_from_days(days);
87
88 Self {
89 year,
90 month,
91 day,
92 hour: (rest / 3600) as u8,
93 minute: (rest % 3600 / 60) as u8,
94 second: (rest % 60) as u8,
95 }
96 }
97
98 pub const fn date(year: i32, month: u8, day: u8) -> Self {
100 Self {
101 year,
102 month,
103 day,
104 hour: 0,
105 minute: 0,
106 second: 0,
107 }
108 }
109
110 pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
117 let bytes = value.as_bytes();
118 let naive = match bytes.len() {
119 8 => value,
120 15 => value,
121 16 if bytes[15] == b'Z' || bytes[15] == b'z' => &value[..15],
122 _ => return Err(IcalRecurRuleError::DateTime),
123 };
124 if naive.len() == 15 && !matches!(naive.as_bytes()[8], b'T' | b't') {
125 return Err(IcalRecurRuleError::DateTime);
126 }
127
128 let num = |range: core::ops::Range<usize>| -> Result<u32, IcalRecurRuleError> {
129 naive
130 .get(range)
131 .ok_or(IcalRecurRuleError::DateTime)?
132 .parse()
133 .map_err(|_| IcalRecurRuleError::DateTime)
134 };
135
136 let mut parsed = Self::date(num(0..4)? as i32, num(4..6)? as u8, num(6..8)? as u8);
137 if naive.len() == 15 {
138 parsed.hour = num(9..11)? as u8;
139 parsed.minute = num(11..13)? as u8;
140 parsed.second = num(13..15)? as u8;
141 }
142
143 let valid_date = (1..=12).contains(&parsed.month)
144 && parsed.day >= 1
145 && parsed.day <= civil::days_in_month(parsed.year, parsed.month);
146 let valid_time = parsed.hour < 24 && parsed.minute < 60 && parsed.second < 61;
147 if !valid_date || !valid_time {
148 return Err(IcalRecurRuleError::DateTime);
149 }
150
151 Ok(parsed)
152 }
153}
154
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum IcalRecurFreq {
162 Secondly,
164 Minutely,
166 Hourly,
168 Daily,
170 Weekly,
172 Monthly,
174 Yearly,
176}
177
178impl FromStr for IcalRecurFreq {
179 type Err = IcalRecurRuleError;
180
181 fn from_str(s: &str) -> Result<Self, Self::Err> {
182 if s.eq_ignore_ascii_case("SECONDLY") {
183 Ok(Self::Secondly)
184 } else if s.eq_ignore_ascii_case("MINUTELY") {
185 Ok(Self::Minutely)
186 } else if s.eq_ignore_ascii_case("HOURLY") {
187 Ok(Self::Hourly)
188 } else if s.eq_ignore_ascii_case("DAILY") {
189 Ok(Self::Daily)
190 } else if s.eq_ignore_ascii_case("WEEKLY") {
191 Ok(Self::Weekly)
192 } else if s.eq_ignore_ascii_case("MONTHLY") {
193 Ok(Self::Monthly)
194 } else if s.eq_ignore_ascii_case("YEARLY") {
195 Ok(Self::Yearly)
196 } else {
197 Err(IcalRecurRuleError::Freq)
198 }
199 }
200}
201
202#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
207pub enum IcalRecurWeekday {
208 Sunday = 0,
210 Monday = 1,
212 Tuesday = 2,
214 Wednesday = 3,
216 Thursday = 4,
218 Friday = 5,
220 Saturday = 6,
222}
223
224impl FromStr for IcalRecurWeekday {
225 type Err = IcalRecurRuleError;
226
227 fn from_str(s: &str) -> Result<Self, Self::Err> {
228 if s.eq_ignore_ascii_case("SU") {
229 Ok(Self::Sunday)
230 } else if s.eq_ignore_ascii_case("MO") {
231 Ok(Self::Monday)
232 } else if s.eq_ignore_ascii_case("TU") {
233 Ok(Self::Tuesday)
234 } else if s.eq_ignore_ascii_case("WE") {
235 Ok(Self::Wednesday)
236 } else if s.eq_ignore_ascii_case("TH") {
237 Ok(Self::Thursday)
238 } else if s.eq_ignore_ascii_case("FR") {
239 Ok(Self::Friday)
240 } else if s.eq_ignore_ascii_case("SA") {
241 Ok(Self::Saturday)
242 } else {
243 Err(IcalRecurRuleError::Weekday)
244 }
245 }
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub struct IcalRecurWeekdayNum {
256 pub ordinal: Option<i16>,
258 pub weekday: IcalRecurWeekday,
260}
261
262impl FromStr for IcalRecurWeekdayNum {
263 type Err = IcalRecurRuleError;
264
265 fn from_str(s: &str) -> Result<Self, Self::Err> {
266 let split = s.char_indices().rev().nth(1).map_or(0, |(index, _)| index);
270 let (ordinal, weekday) = s.split_at(split);
271 let weekday = weekday.parse()?;
272 let ordinal = match ordinal {
273 "" => None,
274 ordinal => Some(ordinal.parse().map_err(IcalRecurRuleError::Ordinal)?),
275 };
276 Ok(Self { ordinal, weekday })
277 }
278}
279
280#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
287pub enum IcalRecurSkip {
288 #[default]
290 Omit,
291 Backward,
293 Forward,
295}
296
297impl FromStr for IcalRecurSkip {
298 type Err = IcalRecurRuleError;
299
300 fn from_str(s: &str) -> Result<Self, Self::Err> {
301 if s.eq_ignore_ascii_case("OMIT") {
302 Ok(Self::Omit)
303 } else if s.eq_ignore_ascii_case("BACKWARD") {
304 Ok(Self::Backward)
305 } else if s.eq_ignore_ascii_case("FORWARD") {
306 Ok(Self::Forward)
307 } else {
308 Err(IcalRecurRuleError::Skip)
309 }
310 }
311}
312
313#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct IcalRecurRule {
322 pub freq: IcalRecurFreq,
324 pub until: Option<IcalRecurDateTime>,
331 pub count: Option<u32>,
333 pub interval: u32,
335 pub by_second: Vec<u8>,
337 pub by_minute: Vec<u8>,
339 pub by_hour: Vec<u8>,
341 pub by_day: Vec<IcalRecurWeekdayNum>,
343 pub by_month_day: Vec<i8>,
345 pub by_year_day: Vec<i16>,
347 pub by_week_no: Vec<i8>,
349 pub by_month: Vec<u8>,
351 pub by_set_pos: Vec<i16>,
357 pub week_start: IcalRecurWeekday,
359 pub scale: Option<alloc::string::String>,
364 pub skip: IcalRecurSkip,
366}
367
368impl IcalRecurRule {
369 pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
376 let mut freq = None;
377 let mut rule = Self {
378 freq: IcalRecurFreq::Daily,
379 until: None,
380 count: None,
381 interval: 1,
382 by_second: Vec::new(),
383 by_minute: Vec::new(),
384 by_hour: Vec::new(),
385 by_day: Vec::new(),
386 by_month_day: Vec::new(),
387 by_year_day: Vec::new(),
388 by_week_no: Vec::new(),
389 by_month: Vec::new(),
390 by_set_pos: Vec::new(),
391 week_start: IcalRecurWeekday::Monday,
392 scale: None,
393 skip: IcalRecurSkip::Omit,
394 };
395
396 for part in value.split(';').filter(|part| !part.is_empty()) {
397 let Some((name, raw)) = part.split_once('=') else {
398 continue;
399 };
400 let name = name.trim();
401 let raw = raw.trim();
402
403 if name.eq_ignore_ascii_case("FREQ") {
404 freq = Some(raw.parse()?);
405 } else if name.eq_ignore_ascii_case("UNTIL") {
406 rule.until = Some(IcalRecurDateTime::parse(raw)?);
407 } else if name.eq_ignore_ascii_case("COUNT") {
408 rule.count = Some(raw.parse().map_err(IcalRecurRuleError::Count)?);
409 } else if name.eq_ignore_ascii_case("INTERVAL") {
410 let interval = raw.parse().map_err(IcalRecurRuleError::Interval)?;
411 if interval == 0 {
412 return Err(IcalRecurRuleError::IntervalZero);
413 }
414 rule.interval = interval;
415 } else if name.eq_ignore_ascii_case("BYSECOND") {
416 rule.by_second = numbers(raw, 0, 60)?;
417 } else if name.eq_ignore_ascii_case("BYMINUTE") {
418 rule.by_minute = numbers(raw, 0, 59)?;
419 } else if name.eq_ignore_ascii_case("BYHOUR") {
420 rule.by_hour = numbers(raw, 0, 23)?;
421 } else if name.eq_ignore_ascii_case("BYDAY") {
422 rule.by_day = weekday_nums(raw)?;
423 } else if name.eq_ignore_ascii_case("BYMONTHDAY") {
424 rule.by_month_day = signed(raw, 1, 31)?;
425 } else if name.eq_ignore_ascii_case("BYYEARDAY") {
426 rule.by_year_day = signed(raw, 1, 366)?;
427 } else if name.eq_ignore_ascii_case("BYWEEKNO") {
428 rule.by_week_no = signed(raw, 1, 53)?;
429 } else if name.eq_ignore_ascii_case("BYMONTH") {
430 rule.by_month = numbers(raw, 1, 12)?;
431 } else if name.eq_ignore_ascii_case("BYSETPOS") {
432 rule.by_set_pos = signed(raw, 1, 366)?;
433 } else if name.eq_ignore_ascii_case("WKST") {
434 rule.week_start = raw.parse()?;
435 } else if name.eq_ignore_ascii_case("RSCALE") {
436 rule.scale = Some(raw.to_ascii_uppercase());
437 } else if name.eq_ignore_ascii_case("SKIP") {
438 rule.skip = raw.parse()?;
439 }
440 }
441
442 rule.freq = freq.ok_or(IcalRecurRuleError::FreqMissing)?;
447
448 Ok(rule)
449 }
450}
451
452fn numbers<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
454where
455 T: TryFrom<i32>,
456{
457 let mut parsed = Vec::new();
458 for item in raw.split(',').filter(|item| !item.is_empty()) {
459 let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
460 if value < min || value > max {
461 return Err(IcalRecurRuleError::Range);
462 }
463 parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
464 }
465 Ok(parsed)
466}
467
468fn signed<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
473where
474 T: TryFrom<i32>,
475{
476 let mut parsed = Vec::new();
477 for item in raw.split(',').filter(|item| !item.is_empty()) {
478 let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
479 let magnitude = value.unsigned_abs() as i32;
480 if value == 0 || magnitude < min || magnitude > max {
481 return Err(IcalRecurRuleError::Range);
482 }
483 parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
484 }
485 Ok(parsed)
486}
487
488fn weekday_nums(raw: &str) -> Result<Vec<IcalRecurWeekdayNum>, IcalRecurRuleError> {
490 let mut parsed = Vec::new();
491 for item in raw.split(',').filter(|item| !item.is_empty()) {
492 parsed.push(item.trim().parse()?);
493 }
494 Ok(parsed)
495}
496
497#[derive(Clone, Debug, PartialEq, Eq)]
499pub enum IcalRecurRuleError {
500 Freq,
502 FreqMissing,
504 Weekday,
506 Skip,
508 DateTime,
510 Count(ParseIntError),
512 Interval(ParseIntError),
514 IntervalZero,
516 Ordinal(ParseIntError),
518 Number(ParseIntError),
520 Range,
522}
523
524impl fmt::Display for IcalRecurRuleError {
525 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526 match self {
527 Self::Freq => write!(f, "Unknown recurrence frequency"),
528 Self::FreqMissing => write!(f, "Missing recurrence frequency"),
529 Self::Weekday => write!(f, "Unknown recurrence weekday"),
530 Self::Skip => write!(f, "Unknown recurrence skip"),
531 Self::DateTime => write!(f, "Invalid recurrence date or date-time"),
532 Self::Count(err) => write!(f, "Invalid recurrence count: {err}"),
533 Self::Interval(err) => write!(f, "Invalid recurrence interval: {err}"),
534 Self::IntervalZero => write!(f, "Recurrence interval cannot be zero"),
535 Self::Ordinal(err) => write!(f, "Invalid recurrence weekday ordinal: {err}"),
536 Self::Number(err) => write!(f, "Invalid recurrence number: {err}"),
537 Self::Range => write!(f, "Recurrence number out of range"),
538 }
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use alloc::vec;
545
546 use crate::recur::*;
547
548 #[test]
549 fn parses_every_part() {
550 let rule = IcalRecurRule::parse(
551 "FREQ=YEARLY;INTERVAL=2;BYMONTH=1,3;BYDAY=-1SU,MO;BYMONTHDAY=1,-1;\
552 BYYEARDAY=100,-1;BYWEEKNO=1,-1;BYHOUR=9;BYMINUTE=30;BYSECOND=0;\
553 BYSETPOS=-1;WKST=SU;UNTIL=20301231T235959Z",
554 )
555 .unwrap();
556
557 assert_eq!(rule.freq, IcalRecurFreq::Yearly);
558 assert_eq!(rule.interval, 2);
559 assert_eq!(rule.by_month, vec![1, 3]);
560 assert_eq!(rule.by_day[0].ordinal, Some(-1));
561 assert_eq!(rule.by_day[0].weekday, IcalRecurWeekday::Sunday);
562 assert_eq!(rule.by_day[1].ordinal, None);
563 assert_eq!(rule.by_month_day, vec![1, -1]);
564 assert_eq!(rule.by_year_day, vec![100, -1]);
565 assert_eq!(rule.by_week_no, vec![1, -1]);
566 assert_eq!(rule.by_set_pos, vec![-1]);
567 assert_eq!(rule.week_start, IcalRecurWeekday::Sunday);
568 assert_eq!(
569 rule.until,
570 Some(IcalRecurDateTime {
571 year: 2030,
572 month: 12,
573 day: 31,
574 hour: 23,
575 minute: 59,
576 second: 59,
577 })
578 );
579 }
580
581 #[test]
582 fn defaults_interval_and_week_start() {
583 let rule = IcalRecurRule::parse("FREQ=DAILY").unwrap();
584 assert_eq!(rule.interval, 1);
585 assert_eq!(rule.week_start, IcalRecurWeekday::Monday);
586 assert_eq!(rule.skip, IcalRecurSkip::Omit);
587 }
588
589 #[test]
590 fn ignores_unknown_parts() {
591 let rule = IcalRecurRule::parse("FREQ=DAILY;X-VENDOR=1;NONSENSE=abc").unwrap();
592 assert_eq!(rule.freq, IcalRecurFreq::Daily);
593 }
594
595 #[test]
596 fn refuses_contradictions() {
597 assert_eq!(
598 IcalRecurRule::parse("INTERVAL=2"),
599 Err(IcalRecurRuleError::FreqMissing)
600 );
601 assert_eq!(
602 IcalRecurRule::parse("FREQ=DAILY;INTERVAL=0"),
603 Err(IcalRecurRuleError::IntervalZero)
604 );
605 assert_eq!(
606 IcalRecurRule::parse("FREQ=FORTNIGHTLY"),
607 Err(IcalRecurRuleError::Freq)
608 );
609 }
610
611 #[test]
612 fn refuses_out_of_range_and_zero_ordinals() {
613 assert_eq!(
614 IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=32"),
615 Err(IcalRecurRuleError::Range)
616 );
617 assert_eq!(
618 IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=0"),
619 Err(IcalRecurRuleError::Range)
620 );
621 assert_eq!(
622 IcalRecurRule::parse("FREQ=YEARLY;BYMONTH=13"),
623 Err(IcalRecurRuleError::Range)
624 );
625 }
626
627 #[test]
628 fn refuses_a_multi_byte_weekday_rather_than_splitting_it() {
629 for value in ["€", "𝄞", "SU€", "-1€", "1FR€"] {
632 assert!(IcalRecurRule::parse(&alloc::format!("FREQ=DAILY;BYDAY={value}")).is_err());
633 }
634 }
635
636 #[test]
637 fn parses_the_three_until_spellings() {
638 let date = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102").unwrap();
639 assert_eq!(date.until, Some(IcalRecurDateTime::date(2030, 1, 2)));
640
641 let local = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405").unwrap();
642 let utc = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405Z").unwrap();
643 assert_eq!(local.until, utc.until);
644 assert_eq!(local.until.unwrap().hour, 3);
645
646 assert_eq!(
647 IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300230"),
648 Err(IcalRecurRuleError::DateTime)
649 );
650 }
651}