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