rrule/parser/
mod.rs

1//! Module for parsing text inputs to a [`Grammar`] which can further be used
2//! to construct an [`crate::RRuleSet`].
3mod content_line;
4mod datetime;
5mod error;
6mod regex;
7mod utils;
8
9use std::str::FromStr;
10
11pub(crate) use content_line::{ContentLine, ContentLineCaptures};
12pub(crate) use datetime::str_to_weekday;
13pub use error::ParseError;
14
15use crate::RRule;
16
17use self::content_line::{PropertyName, StartDateContentLine};
18
19/// Grammar represents a well-formatted rrule input.
20#[derive(Debug, PartialEq)]
21pub(crate) struct Grammar {
22    pub start: Option<StartDateContentLine>,
23    pub content_lines: Vec<ContentLine>,
24}
25
26impl FromStr for Grammar {
27    type Err = ParseError;
28
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        let content_lines_parts = s
31            .lines()
32            .map(ContentLineCaptures::new)
33            .collect::<Result<Vec<_>, _>>()?;
34
35        let start = content_lines_parts
36            .iter()
37            .find(|parts| matches!(parts.property_name, PropertyName::DtStart))
38            .map(StartDateContentLine::try_from)
39            .transpose()?;
40
41        let mut content_lines = vec![];
42
43        for parts in content_lines_parts {
44            let line = match parts.property_name {
45                PropertyName::RRule => {
46                    let rrule = RRule::try_from(parts)?;
47                    ContentLine::RRule(rrule)
48                }
49                PropertyName::ExRule => {
50                    let rrule = RRule::try_from(parts)?;
51                    ContentLine::ExRule(rrule)
52                }
53                PropertyName::RDate => ContentLine::RDate(TryFrom::try_from(parts)?),
54                PropertyName::ExDate => ContentLine::ExDate(TryFrom::try_from(parts)?),
55                PropertyName::DtStart => {
56                    // Nothing to do
57                    continue;
58                }
59            };
60            content_lines.push(line);
61        }
62
63        // Need to be at least one `RDATE` or `RRULE`
64        if !content_lines
65            .iter()
66            .any(|line| matches!(line, ContentLine::RRule(_) | ContentLine::RDate(_)))
67        {
68            return Err(ParseError::MissingDateGenerationRules);
69        }
70
71        Ok(Self {
72            start,
73            content_lines,
74        })
75    }
76}
77
78#[cfg(test)]
79mod test {
80    use chrono::{TimeZone, Weekday};
81
82    use super::*;
83    use crate::{core::Tz, parser::content_line::ContentLine, Frequency, NWeekday, RRule};
84
85    const UTC: Tz = Tz::UTC;
86    const BERLIN: Tz = Tz::Europe__Berlin;
87
88    #[test]
89    fn parses_valid_input_to_grammar() {
90        let tests = [
91(
92    "DTSTART:19970902T090000Z\nRRULE:FREQ=YEARLY;COUNT=3\n", Grammar {
93    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(1997, 9, 2,9, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
94    content_lines: vec![
95        ContentLine::RRule(RRule {
96            freq: Frequency::Yearly,
97            count: Some(3),
98            ..Default::default()
99        })
100    ]
101}
102),
103("DTSTART:20120201T093000Z\nRRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR", Grammar {
104    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,9, 30, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
105    content_lines: vec![
106        ContentLine::RRule(RRule {
107            freq: Frequency::Weekly,
108            interval: 5,
109            until: Some(UTC.with_ymd_and_hms(2013, 1, 30,23, 0, 0).unwrap()),
110            by_weekday: vec![NWeekday::Every(Weekday::Mon), NWeekday::Every(Weekday::Fri)],
111            ..Default::default()
112        })
113    ]
114}),
115("DTSTART:20120201T120000Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000", Grammar {
116    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,12, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
117    content_lines: vec![
118        ContentLine::RRule(RRule {
119            freq: Frequency::Daily,
120            count: Some(5),
121            ..Default::default()
122        }),
123        ContentLine::ExDate(vec![
124            BERLIN.with_ymd_and_hms(2012, 2, 2,13, 0, 0).unwrap(),
125            BERLIN.with_ymd_and_hms(2012, 2, 3,13, 0, 0).unwrap(),
126        ])
127    ]
128}),
129("DTSTART:20120201T120000Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000\nEXRULE:FREQ=WEEKLY;COUNT=10", Grammar {
130    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,12, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
131    content_lines: vec![
132        ContentLine::RRule(RRule {
133            freq: Frequency::Daily,
134            count: Some(5),
135            ..Default::default()
136        }),
137        ContentLine::ExDate(vec![
138            BERLIN.with_ymd_and_hms(2012, 2, 2,13, 0, 0).unwrap(),
139            BERLIN.with_ymd_and_hms(2012, 2, 3,13, 0, 0).unwrap(),
140        ]),
141        ContentLine::ExRule(RRule {
142            freq: Frequency::Weekly,
143            count: Some(10),
144            ..Default::default()
145        }),
146    ]
147})
148        ];
149        for (input, expected_grammar) in tests {
150            let grammar = Grammar::from_str(input);
151            assert_eq!(grammar, Ok(expected_grammar));
152        }
153    }
154
155    #[test]
156    fn rejects_input_without_date_generation() {
157        let tests = [
158"DTSTART:19970902T090000Z",
159"DTSTART:20120201T093000Z\nEXRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR",
160"DTSTART:20120201T120000Z\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
161"DTSTART:20120201T120000Z\nEXRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000"
162        ];
163        for input in tests {
164            let res = Grammar::from_str(input);
165            assert_eq!(res, Err(ParseError::MissingDateGenerationRules));
166        }
167    }
168
169    #[test]
170    fn allows_input_without_start_date() {
171        let tests = [
172            "RRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR",
173            "RDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
174            "RRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
175        ];
176        for input in tests {
177            let res = Grammar::from_str(input);
178            assert!(res.is_ok());
179        }
180    }
181}