1use alloc::vec::Vec;
36use core::{fmt, num::ParseIntError, str::FromStr};
37
38mod civil;
39pub mod expand;
40pub mod set;
41pub mod validate;
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
50pub struct IcalRecurDateTime {
51 pub year: i32,
53 pub month: u8,
55 pub day: u8,
57 pub hour: u8,
59 pub minute: u8,
61 pub second: u8,
63}
64
65impl IcalRecurDateTime {
66 pub const fn seconds(&self) -> i64 {
72 civil::days_from_civil(self.year, self.month, self.day) * 86_400
73 + self.hour as i64 * 3600
74 + self.minute as i64 * 60
75 + self.second as i64
76 }
77
78 pub const fn from_seconds(seconds: i64) -> Self {
81 let days = seconds.div_euclid(86_400);
82 let rest = seconds.rem_euclid(86_400);
83 let (year, month, day) = civil::civil_from_days(days);
84
85 Self {
86 year,
87 month,
88 day,
89 hour: (rest / 3600) as u8,
90 minute: (rest % 3600 / 60) as u8,
91 second: (rest % 60) as u8,
92 }
93 }
94
95 pub const fn date(year: i32, month: u8, day: u8) -> Self {
97 Self {
98 year,
99 month,
100 day,
101 hour: 0,
102 minute: 0,
103 second: 0,
104 }
105 }
106
107 pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
114 let bytes = value.as_bytes();
115 let naive = match bytes.len() {
116 8 => value,
117 15 => value,
118 16 if bytes[15] == b'Z' || bytes[15] == b'z' => &value[..15],
119 _ => return Err(IcalRecurRuleError::DateTime),
120 };
121 if naive.len() == 15 && !matches!(naive.as_bytes()[8], b'T' | b't') {
122 return Err(IcalRecurRuleError::DateTime);
123 }
124
125 let num = |range: core::ops::Range<usize>| -> Result<u32, IcalRecurRuleError> {
126 naive
127 .get(range)
128 .ok_or(IcalRecurRuleError::DateTime)?
129 .parse()
130 .map_err(|_| IcalRecurRuleError::DateTime)
131 };
132
133 let mut parsed = Self::date(num(0..4)? as i32, num(4..6)? as u8, num(6..8)? as u8);
134 if naive.len() == 15 {
135 parsed.hour = num(9..11)? as u8;
136 parsed.minute = num(11..13)? as u8;
137 parsed.second = num(13..15)? as u8;
138 }
139
140 let valid_date = (1..=12).contains(&parsed.month)
141 && parsed.day >= 1
142 && parsed.day <= civil::days_in_month(parsed.year, parsed.month);
143 let valid_time = parsed.hour < 24 && parsed.minute < 60 && parsed.second < 61;
144 if !valid_date || !valid_time {
145 return Err(IcalRecurRuleError::DateTime);
146 }
147
148 Ok(parsed)
149 }
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub enum IcalRecurFreq {
159 Secondly,
161 Minutely,
163 Hourly,
165 Daily,
167 Weekly,
169 Monthly,
171 Yearly,
173}
174
175impl FromStr for IcalRecurFreq {
176 type Err = IcalRecurRuleError;
177
178 fn from_str(s: &str) -> Result<Self, Self::Err> {
179 if s.eq_ignore_ascii_case("SECONDLY") {
180 Ok(Self::Secondly)
181 } else if s.eq_ignore_ascii_case("MINUTELY") {
182 Ok(Self::Minutely)
183 } else if s.eq_ignore_ascii_case("HOURLY") {
184 Ok(Self::Hourly)
185 } else if s.eq_ignore_ascii_case("DAILY") {
186 Ok(Self::Daily)
187 } else if s.eq_ignore_ascii_case("WEEKLY") {
188 Ok(Self::Weekly)
189 } else if s.eq_ignore_ascii_case("MONTHLY") {
190 Ok(Self::Monthly)
191 } else if s.eq_ignore_ascii_case("YEARLY") {
192 Ok(Self::Yearly)
193 } else {
194 Err(IcalRecurRuleError::Freq)
195 }
196 }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
204pub enum IcalRecurWeekday {
205 Sunday = 0,
207 Monday = 1,
209 Tuesday = 2,
211 Wednesday = 3,
213 Thursday = 4,
215 Friday = 5,
217 Saturday = 6,
219}
220
221impl FromStr for IcalRecurWeekday {
222 type Err = IcalRecurRuleError;
223
224 fn from_str(s: &str) -> Result<Self, Self::Err> {
225 if s.eq_ignore_ascii_case("SU") {
226 Ok(Self::Sunday)
227 } else if s.eq_ignore_ascii_case("MO") {
228 Ok(Self::Monday)
229 } else if s.eq_ignore_ascii_case("TU") {
230 Ok(Self::Tuesday)
231 } else if s.eq_ignore_ascii_case("WE") {
232 Ok(Self::Wednesday)
233 } else if s.eq_ignore_ascii_case("TH") {
234 Ok(Self::Thursday)
235 } else if s.eq_ignore_ascii_case("FR") {
236 Ok(Self::Friday)
237 } else if s.eq_ignore_ascii_case("SA") {
238 Ok(Self::Saturday)
239 } else {
240 Err(IcalRecurRuleError::Weekday)
241 }
242 }
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub struct IcalRecurWeekdayNum {
253 pub ordinal: Option<i16>,
255 pub weekday: IcalRecurWeekday,
257}
258
259impl FromStr for IcalRecurWeekdayNum {
260 type Err = IcalRecurRuleError;
261
262 fn from_str(s: &str) -> Result<Self, Self::Err> {
263 let split = s.char_indices().rev().nth(1).map_or(0, |(index, _)| index);
267 let (ordinal, weekday) = s.split_at(split);
268 let weekday = weekday.parse()?;
269 let ordinal = match ordinal {
270 "" => None,
271 ordinal => Some(ordinal.parse().map_err(IcalRecurRuleError::Ordinal)?),
272 };
273 Ok(Self { ordinal, weekday })
274 }
275}
276
277#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
284pub enum IcalRecurSkip {
285 #[default]
287 Omit,
288 Backward,
290 Forward,
292}
293
294impl FromStr for IcalRecurSkip {
295 type Err = IcalRecurRuleError;
296
297 fn from_str(s: &str) -> Result<Self, Self::Err> {
298 if s.eq_ignore_ascii_case("OMIT") {
299 Ok(Self::Omit)
300 } else if s.eq_ignore_ascii_case("BACKWARD") {
301 Ok(Self::Backward)
302 } else if s.eq_ignore_ascii_case("FORWARD") {
303 Ok(Self::Forward)
304 } else {
305 Err(IcalRecurRuleError::Skip)
306 }
307 }
308}
309
310#[derive(Clone, Debug, PartialEq, Eq)]
318pub struct IcalRecurRule {
319 pub freq: IcalRecurFreq,
321 pub until: Option<IcalRecurDateTime>,
328 pub count: Option<u32>,
330 pub interval: u32,
332 pub by_second: Vec<u8>,
334 pub by_minute: Vec<u8>,
336 pub by_hour: Vec<u8>,
338 pub by_day: Vec<IcalRecurWeekdayNum>,
340 pub by_month_day: Vec<i8>,
342 pub by_year_day: Vec<i16>,
344 pub by_week_no: Vec<i8>,
346 pub by_month: Vec<u8>,
348 pub by_set_pos: Vec<i16>,
354 pub week_start: IcalRecurWeekday,
356 pub scale: Option<alloc::string::String>,
361 pub skip: IcalRecurSkip,
363}
364
365impl IcalRecurRule {
366 pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
373 let mut freq = None;
374 let mut rule = Self {
375 freq: IcalRecurFreq::Daily,
376 until: None,
377 count: None,
378 interval: 1,
379 by_second: Vec::new(),
380 by_minute: Vec::new(),
381 by_hour: Vec::new(),
382 by_day: Vec::new(),
383 by_month_day: Vec::new(),
384 by_year_day: Vec::new(),
385 by_week_no: Vec::new(),
386 by_month: Vec::new(),
387 by_set_pos: Vec::new(),
388 week_start: IcalRecurWeekday::Monday,
389 scale: None,
390 skip: IcalRecurSkip::Omit,
391 };
392
393 for part in value.split(';').filter(|part| !part.is_empty()) {
394 let Some((name, raw)) = part.split_once('=') else {
395 continue;
396 };
397 let name = name.trim();
398 let raw = raw.trim();
399
400 if name.eq_ignore_ascii_case("FREQ") {
401 freq = Some(raw.parse()?);
402 } else if name.eq_ignore_ascii_case("UNTIL") {
403 rule.until = Some(IcalRecurDateTime::parse(raw)?);
404 } else if name.eq_ignore_ascii_case("COUNT") {
405 rule.count = Some(raw.parse().map_err(IcalRecurRuleError::Count)?);
406 } else if name.eq_ignore_ascii_case("INTERVAL") {
407 let interval = raw.parse().map_err(IcalRecurRuleError::Interval)?;
408 if interval == 0 {
409 return Err(IcalRecurRuleError::IntervalZero);
410 }
411 rule.interval = interval;
412 } else if name.eq_ignore_ascii_case("BYSECOND") {
413 rule.by_second = numbers(raw, 0, 60)?;
414 } else if name.eq_ignore_ascii_case("BYMINUTE") {
415 rule.by_minute = numbers(raw, 0, 59)?;
416 } else if name.eq_ignore_ascii_case("BYHOUR") {
417 rule.by_hour = numbers(raw, 0, 23)?;
418 } else if name.eq_ignore_ascii_case("BYDAY") {
419 rule.by_day = weekday_nums(raw)?;
420 } else if name.eq_ignore_ascii_case("BYMONTHDAY") {
421 rule.by_month_day = signed(raw, 1, 31)?;
422 } else if name.eq_ignore_ascii_case("BYYEARDAY") {
423 rule.by_year_day = signed(raw, 1, 366)?;
424 } else if name.eq_ignore_ascii_case("BYWEEKNO") {
425 rule.by_week_no = signed(raw, 1, 53)?;
426 } else if name.eq_ignore_ascii_case("BYMONTH") {
427 rule.by_month = numbers(raw, 1, 12)?;
428 } else if name.eq_ignore_ascii_case("BYSETPOS") {
429 rule.by_set_pos = signed(raw, 1, 366)?;
430 } else if name.eq_ignore_ascii_case("WKST") {
431 rule.week_start = raw.parse()?;
432 } else if name.eq_ignore_ascii_case("RSCALE") {
433 rule.scale = Some(raw.to_ascii_uppercase());
434 } else if name.eq_ignore_ascii_case("SKIP") {
435 rule.skip = raw.parse()?;
436 }
437 }
438
439 rule.freq = freq.ok_or(IcalRecurRuleError::FreqMissing)?;
444
445 Ok(rule)
446 }
447}
448
449fn numbers<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
451where
452 T: TryFrom<i32>,
453{
454 let mut parsed = Vec::new();
455 for item in raw.split(',').filter(|item| !item.is_empty()) {
456 let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
457 if value < min || value > max {
458 return Err(IcalRecurRuleError::Range);
459 }
460 parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
461 }
462 Ok(parsed)
463}
464
465fn signed<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
470where
471 T: TryFrom<i32>,
472{
473 let mut parsed = Vec::new();
474 for item in raw.split(',').filter(|item| !item.is_empty()) {
475 let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
476 let magnitude = value.unsigned_abs() as i32;
477 if value == 0 || magnitude < min || magnitude > max {
478 return Err(IcalRecurRuleError::Range);
479 }
480 parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
481 }
482 Ok(parsed)
483}
484
485fn weekday_nums(raw: &str) -> Result<Vec<IcalRecurWeekdayNum>, IcalRecurRuleError> {
487 let mut parsed = Vec::new();
488 for item in raw.split(',').filter(|item| !item.is_empty()) {
489 parsed.push(item.trim().parse()?);
490 }
491 Ok(parsed)
492}
493
494#[derive(Clone, Debug, PartialEq, Eq)]
496pub enum IcalRecurRuleError {
497 Freq,
499 FreqMissing,
501 Weekday,
503 Skip,
505 DateTime,
507 Count(ParseIntError),
509 Interval(ParseIntError),
511 IntervalZero,
513 Ordinal(ParseIntError),
515 Number(ParseIntError),
517 Range,
519}
520
521impl fmt::Display for IcalRecurRuleError {
522 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523 match self {
524 Self::Freq => write!(f, "Unknown recurrence frequency"),
525 Self::FreqMissing => write!(f, "Missing recurrence frequency"),
526 Self::Weekday => write!(f, "Unknown recurrence weekday"),
527 Self::Skip => write!(f, "Unknown recurrence skip"),
528 Self::DateTime => write!(f, "Invalid recurrence date or date-time"),
529 Self::Count(err) => write!(f, "Invalid recurrence count: {err}"),
530 Self::Interval(err) => write!(f, "Invalid recurrence interval: {err}"),
531 Self::IntervalZero => write!(f, "Recurrence interval cannot be zero"),
532 Self::Ordinal(err) => write!(f, "Invalid recurrence weekday ordinal: {err}"),
533 Self::Number(err) => write!(f, "Invalid recurrence number: {err}"),
534 Self::Range => write!(f, "Recurrence number out of range"),
535 }
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use alloc::vec;
542
543 use crate::recur::*;
544
545 #[test]
546 fn parses_every_part() {
547 let rule = IcalRecurRule::parse(
548 "FREQ=YEARLY;INTERVAL=2;BYMONTH=1,3;BYDAY=-1SU,MO;BYMONTHDAY=1,-1;\
549 BYYEARDAY=100,-1;BYWEEKNO=1,-1;BYHOUR=9;BYMINUTE=30;BYSECOND=0;\
550 BYSETPOS=-1;WKST=SU;UNTIL=20301231T235959Z",
551 )
552 .unwrap();
553
554 assert_eq!(rule.freq, IcalRecurFreq::Yearly);
555 assert_eq!(rule.interval, 2);
556 assert_eq!(rule.by_month, vec![1, 3]);
557 assert_eq!(rule.by_day[0].ordinal, Some(-1));
558 assert_eq!(rule.by_day[0].weekday, IcalRecurWeekday::Sunday);
559 assert_eq!(rule.by_day[1].ordinal, None);
560 assert_eq!(rule.by_month_day, vec![1, -1]);
561 assert_eq!(rule.by_year_day, vec![100, -1]);
562 assert_eq!(rule.by_week_no, vec![1, -1]);
563 assert_eq!(rule.by_set_pos, vec![-1]);
564 assert_eq!(rule.week_start, IcalRecurWeekday::Sunday);
565 assert_eq!(
566 rule.until,
567 Some(IcalRecurDateTime {
568 year: 2030,
569 month: 12,
570 day: 31,
571 hour: 23,
572 minute: 59,
573 second: 59,
574 })
575 );
576 }
577
578 #[test]
579 fn defaults_interval_and_week_start() {
580 let rule = IcalRecurRule::parse("FREQ=DAILY").unwrap();
581 assert_eq!(rule.interval, 1);
582 assert_eq!(rule.week_start, IcalRecurWeekday::Monday);
583 assert_eq!(rule.skip, IcalRecurSkip::Omit);
584 }
585
586 #[test]
587 fn ignores_unknown_parts() {
588 let rule = IcalRecurRule::parse("FREQ=DAILY;X-VENDOR=1;NONSENSE=abc").unwrap();
589 assert_eq!(rule.freq, IcalRecurFreq::Daily);
590 }
591
592 #[test]
593 fn refuses_contradictions() {
594 assert_eq!(
595 IcalRecurRule::parse("INTERVAL=2"),
596 Err(IcalRecurRuleError::FreqMissing)
597 );
598 assert_eq!(
599 IcalRecurRule::parse("FREQ=DAILY;INTERVAL=0"),
600 Err(IcalRecurRuleError::IntervalZero)
601 );
602 assert_eq!(
603 IcalRecurRule::parse("FREQ=FORTNIGHTLY"),
604 Err(IcalRecurRuleError::Freq)
605 );
606 }
607
608 #[test]
609 fn refuses_out_of_range_and_zero_ordinals() {
610 assert_eq!(
611 IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=32"),
612 Err(IcalRecurRuleError::Range)
613 );
614 assert_eq!(
615 IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=0"),
616 Err(IcalRecurRuleError::Range)
617 );
618 assert_eq!(
619 IcalRecurRule::parse("FREQ=YEARLY;BYMONTH=13"),
620 Err(IcalRecurRuleError::Range)
621 );
622 }
623
624 #[test]
625 fn refuses_a_multi_byte_weekday_rather_than_splitting_it() {
626 for value in ["€", "𝄞", "SU€", "-1€", "1FR€"] {
629 assert!(IcalRecurRule::parse(&alloc::format!("FREQ=DAILY;BYDAY={value}")).is_err());
630 }
631 }
632
633 #[test]
634 fn parses_the_three_until_spellings() {
635 let date = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102").unwrap();
636 assert_eq!(date.until, Some(IcalRecurDateTime::date(2030, 1, 2)));
637
638 let local = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405").unwrap();
639 let utc = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405Z").unwrap();
640 assert_eq!(local.until, utc.until);
641 assert_eq!(local.until.unwrap().hour, 3);
642
643 assert_eq!(
644 IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300230"),
645 Err(IcalRecurRuleError::DateTime)
646 );
647 }
648}