rrule 0.14.0

A pure Rust implementation of recurrence rules as defined in the iCalendar RFC.
Documentation
//! Module for parsing text inputs to a [`Grammar`] which can further be used
//! to construct an [`crate::RRuleSet`].
mod content_line;
mod datetime;
mod error;
mod regex;
mod utils;

use std::str::FromStr;

pub(crate) use content_line::{ContentLine, ContentLineCaptures};
pub(crate) use datetime::str_to_weekday;
pub use error::ParseError;

use crate::RRule;

use self::content_line::{PropertyName, StartDateContentLine};

/// Grammar represents a well-formatted rrule input.
#[derive(Debug, PartialEq)]
pub(crate) struct Grammar {
    pub start: Option<StartDateContentLine>,
    pub content_lines: Vec<ContentLine>,
}

impl FromStr for Grammar {
    type Err = ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let content_lines_parts = s
            .lines()
            .map(ContentLineCaptures::new)
            .collect::<Result<Vec<_>, _>>()?;

        let start = content_lines_parts
            .iter()
            .find(|parts| matches!(parts.property_name, PropertyName::DtStart))
            .map(StartDateContentLine::try_from)
            .transpose()?;

        let mut content_lines = vec![];

        for parts in content_lines_parts {
            let line = match parts.property_name {
                PropertyName::RRule => {
                    let rrule = RRule::try_from(parts)?;
                    ContentLine::RRule(rrule)
                }
                PropertyName::ExRule => {
                    let rrule = RRule::try_from(parts)?;
                    ContentLine::ExRule(rrule)
                }
                PropertyName::RDate => ContentLine::RDate(TryFrom::try_from(parts)?),
                PropertyName::ExDate => ContentLine::ExDate(TryFrom::try_from(parts)?),
                PropertyName::DtStart => {
                    // Nothing to do
                    continue;
                }
            };
            content_lines.push(line);
        }

        // Need to be at least one `RDATE` or `RRULE`
        if !content_lines
            .iter()
            .any(|line| matches!(line, ContentLine::RRule(_) | ContentLine::RDate(_)))
        {
            return Err(ParseError::MissingDateGenerationRules);
        }

        Ok(Self {
            start,
            content_lines,
        })
    }
}

#[cfg(test)]
mod test {
    use chrono::{TimeZone, Weekday};

    use super::*;
    use crate::{core::Tz, parser::content_line::ContentLine, Frequency, NWeekday, RRule};

    const UTC: Tz = Tz::UTC;
    const BERLIN: Tz = Tz::Europe__Berlin;

    #[test]
    fn parses_valid_input_to_grammar() {
        let tests = [
(
    "DTSTART:19970902T090000Z\nRRULE:FREQ=YEARLY;COUNT=3\n", Grammar {
    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(1997, 9, 2,9, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
    content_lines: vec![
        ContentLine::RRule(RRule {
            freq: Frequency::Yearly,
            count: Some(3),
            ..Default::default()
        })
    ]
}
),
("DTSTART:20120201T093000Z\nRRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR", Grammar {
    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,9, 30, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
    content_lines: vec![
        ContentLine::RRule(RRule {
            freq: Frequency::Weekly,
            interval: 5,
            until: Some(UTC.with_ymd_and_hms(2013, 1, 30,23, 0, 0).unwrap()),
            by_weekday: vec![NWeekday::Every(Weekday::Mon), NWeekday::Every(Weekday::Fri)],
            ..Default::default()
        })
    ]
}),
("DTSTART:20120201T120000Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000", Grammar {
    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,12, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
    content_lines: vec![
        ContentLine::RRule(RRule {
            freq: Frequency::Daily,
            count: Some(5),
            ..Default::default()
        }),
        ContentLine::ExDate(vec![
            BERLIN.with_ymd_and_hms(2012, 2, 2,13, 0, 0).unwrap(),
            BERLIN.with_ymd_and_hms(2012, 2, 3,13, 0, 0).unwrap(),
        ])
    ]
}),
("DTSTART:20120201T120000Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000\nEXRULE:FREQ=WEEKLY;COUNT=10", Grammar {
    start: Some(StartDateContentLine { datetime: UTC.with_ymd_and_hms(2012, 2, 1,12, 0, 0).unwrap(), timezone: Some(UTC), value: "DATE-TIME" }),
    content_lines: vec![
        ContentLine::RRule(RRule {
            freq: Frequency::Daily,
            count: Some(5),
            ..Default::default()
        }),
        ContentLine::ExDate(vec![
            BERLIN.with_ymd_and_hms(2012, 2, 2,13, 0, 0).unwrap(),
            BERLIN.with_ymd_and_hms(2012, 2, 3,13, 0, 0).unwrap(),
        ]),
        ContentLine::ExRule(RRule {
            freq: Frequency::Weekly,
            count: Some(10),
            ..Default::default()
        }),
    ]
})
        ];
        for (input, expected_grammar) in tests {
            let grammar = Grammar::from_str(input);
            assert_eq!(grammar, Ok(expected_grammar));
        }
    }

    #[test]
    fn rejects_input_without_date_generation() {
        let tests = [
"DTSTART:19970902T090000Z",
"DTSTART:20120201T093000Z\nEXRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR",
"DTSTART:20120201T120000Z\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
"DTSTART:20120201T120000Z\nEXRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000"
        ];
        for input in tests {
            let res = Grammar::from_str(input);
            assert_eq!(res, Err(ParseError::MissingDateGenerationRules));
        }
    }

    #[test]
    fn allows_input_without_start_date() {
        let tests = [
            "RRULE:FREQ=WEEKLY;INTERVAL=5;UNTIL=20130130T230000Z;BYDAY=MO,FR",
            "RDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
            "RRULE:FREQ=DAILY;COUNT=5\nEXDATE;TZID=Europe/Berlin:20120202T130000,20120203T130000",
        ];
        for input in tests {
            let res = Grammar::from_str(input);
            assert!(res.is_ok());
        }
    }
}