rrule 0.14.0

A pure Rust implementation of recurrence rules as defined in the iCalendar RFC.
Documentation
use std::collections::HashMap;

use super::{
    content_line_parts::ContentLineCaptures, date_content_line::DateParameter,
    parameters::parse_parameters,
};
use crate::{
    core::Tz,
    parser::{
        datetime::{datestring_to_date, parse_timezone},
        ParseError,
    },
};

const UTC: Tz = Tz::UTC;

#[derive(Debug, PartialEq)]
pub(crate) struct StartDateContentLine {
    pub datetime: chrono::DateTime<Tz>,
    pub timezone: Option<Tz>,
    pub value: &'static str,
}

impl TryFrom<&ContentLineCaptures<'_>> for StartDateContentLine {
    type Error = ParseError;

    fn try_from(content_line: &ContentLineCaptures) -> Result<Self, Self::Error> {
        let parameters: HashMap<DateParameter, String> = content_line
            .parameters
            .as_ref()
            .map(|p| parse_parameters(p))
            .transpose()?
            .unwrap_or_default();

        let mut timezone = parameters
            .get(&DateParameter::Timezone)
            .map(|tz| parse_timezone(tz))
            .transpose()?;
        if timezone.is_none() && content_line.value.to_uppercase().ends_with('Z') {
            timezone = Some(UTC);
        }

        let value_in_parameter = parameters.get(&DateParameter::Value);
        let value = if content_line.value.len() > 8 {
            "DATE-TIME"
        } else {
            "DATE"
        };
        if let Some(value_in_parameter) = value_in_parameter {
            if value_in_parameter != value {
                return Err(ParseError::ParameterValueMismatch {
                    parameter: "VALUE".into(),
                    parameter_value: value_in_parameter.into(),
                    found_value: value.into(),
                });
            }
        }

        let datetime = datestring_to_date(content_line.value, timezone, "DTSTART")?;

        Ok(Self {
            datetime,
            timezone,
            value,
        })
    }
}

#[cfg(test)]
mod tests {
    use chrono::TimeZone;

    use crate::parser::content_line::PropertyName;

    use super::*;

    #[test]
    fn parses_dtstart_content_line() {
        let tests = [
            (
                ContentLineCaptures {
                    property_name: PropertyName::DtStart,
                    parameters: None,
                    value: "19970714T123000Z",
                },
                StartDateContentLine {
                    datetime: UTC.with_ymd_and_hms(1997, 7, 14, 12, 30, 0).unwrap(),
                    timezone: Some(UTC),
                    value: "DATE-TIME",
                },
            ),
            (
                ContentLineCaptures {
                    property_name: PropertyName::DtStart,
                    parameters: Some("VALUE=DATE;TZID=UTC"),
                    value: "19970101",
                },
                StartDateContentLine {
                    datetime: UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap(),
                    timezone: Some(UTC),
                    value: "DATE",
                },
            ),
            (
                ContentLineCaptures {
                    property_name: PropertyName::DtStart,
                    parameters: Some("TZID=UTC"),
                    value: "19970101",
                },
                StartDateContentLine {
                    datetime: UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap(),
                    timezone: Some(UTC),
                    value: "DATE",
                },
            ),
        ];

        for (input, expected_output) in tests {
            let output = TryFrom::try_from(&input);
            assert_eq!(output, Ok(expected_output));
        }
    }

    #[test]
    fn rejects_invalid_dtstart() {
        let tests = [
            ContentLineCaptures {
                property_name: PropertyName::DtStart,
                parameters: None,
                value: "20120201120000Z",
            },
            ContentLineCaptures {
                property_name: PropertyName::DtStart,
                parameters: None,
                value: "2012",
            },
            ContentLineCaptures {
                property_name: PropertyName::DtStart,
                parameters: None,
                value: "",
            },
        ];

        for input in tests {
            let output = StartDateContentLine::try_from(&input);
            assert_eq!(
                output,
                Err(ParseError::InvalidDateTime {
                    value: input.value.into(),
                    property: "DTSTART".into()
                })
            );
        }
    }

    #[test]
    fn reject_invalid_timezone_in_start_date() {
        let content = ContentLineCaptures {
            property_name: PropertyName::DtStart,
            parameters: Some("TZID=America/Everywhere"),
            value: "20120251T023000Z",
        };
        let res = StartDateContentLine::try_from(&content);
        assert!(res.is_err());
        let err = res.unwrap_err();
        assert_eq!(
            err,
            ParseError::InvalidTimezone("America/Everywhere".into())
        );
    }

    #[test]
    fn reject_value_mismatch_with_parameter() {
        let content = ContentLineCaptures {
            property_name: PropertyName::DtStart,
            parameters: Some("VALUE=DATE"),
            value: "20120251T023000Z",
        };
        let res = StartDateContentLine::try_from(&content);
        assert!(res.is_err());
        let err = res.unwrap_err();
        assert_eq!(
            err,
            ParseError::ParameterValueMismatch {
                parameter: "VALUE".into(),
                parameter_value: "DATE".into(),
                found_value: "DATE-TIME".into()
            }
        );
    }
}