caldata 0.16.2

Ical/Vcard parser for Rust
Documentation
use crate::{parser::ContentLine, types::Value};
use chrono::Duration;
use lazy_static::lazy_static;

lazy_static! {
    static ref RE_DURATION: regex::Regex = regex::Regex::new(
        r"(?x)
        ^(?<sign>[+-])?
        P (
            (
                ((?P<D>\d+)D)?  # days
                (
                    T
                    ((?P<H>\d+)H)?
                    ((?P<M>\d+)M)?
                    ((?P<S>\d+)S)?
                )?
            )  # dur-date,dur-time
            | (
                ((?P<W>\d+)W)?
            )  # dur-week
        )
        $"
    )
    .unwrap();
}

impl TryFrom<&ContentLine> for Option<chrono::Duration> {
    type Error = InvalidDuration;

    fn try_from(value: &ContentLine) -> Result<Self, Self::Error> {
        Ok(Some(parse_duration(&value.value)?))
    }
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[error("Invalid duration: {0}")]
pub struct InvalidDuration(String);

pub fn parse_duration(string: &str) -> Result<Duration, InvalidDuration> {
    let captures = RE_DURATION
        .captures(string)
        .ok_or(InvalidDuration(string.to_owned()))?;

    let mut duration = Duration::zero();
    if let Some(weeks) = captures.name("W") {
        duration += Duration::weeks(weeks.as_str().parse().unwrap());
    }
    if let Some(days) = captures.name("D") {
        duration += Duration::days(days.as_str().parse().unwrap());
    }
    if let Some(hours) = captures.name("H") {
        duration += Duration::hours(hours.as_str().parse().unwrap());
    }
    if let Some(minutes) = captures.name("M") {
        duration += Duration::minutes(minutes.as_str().parse().unwrap());
    }
    if let Some(seconds) = captures.name("S") {
        duration += Duration::seconds(seconds.as_str().parse().unwrap());
    }
    if let Some(sign) = captures.name("sign")
        && sign.as_str() == "-"
    {
        duration = -duration;
    }

    Ok(duration)
}

impl Value for Duration {
    fn value_type(&self) -> Option<&'static str> {
        Some("DURATION")
    }

    fn value(&self) -> String {
        if self.is_zero() {
            return "PT0S".to_owned();
        }
        let mut abs_duration = self.abs();
        let mut out = String::new();
        if self < &abs_duration {
            out.push('-');
        }
        out.push('P');

        // Return weeks if duration can be expressed exactly by number of weeks
        let weeks = abs_duration.num_weeks();
        if weeks > 0 && abs_duration == Duration::weeks(weeks) {
            out.push_str(&format!("{weeks}W"));
            return out;
        }

        let days = abs_duration.num_days();
        if days > 0 {
            out.push_str(&format!("{days}D"));
            abs_duration -= Duration::days(days);
        }
        if abs_duration.is_zero() {
            return out;
        }

        out.push('T');

        let hours = abs_duration.num_hours();
        if hours > 0 {
            out.push_str(&format!("{hours}H"));
            abs_duration -= Duration::hours(hours);
        }
        let minutes = abs_duration.num_minutes();
        if minutes > 0 {
            out.push_str(&format!("{minutes}M"));
            abs_duration -= Duration::minutes(minutes);
        }
        let seconds = abs_duration.num_seconds();
        if seconds > 0 {
            out.push_str(&format!("{seconds}S"));
            abs_duration -= Duration::seconds(seconds);
        }

        out
    }
}

#[cfg(test)]
mod tests {
    use crate::types::Value;

    use super::parse_duration;
    use chrono::Duration;
    use rstest::rstest;

    #[test]
    fn test_parse_duration() {
        assert!(parse_duration("P1D12W").is_err());
        assert!(parse_duration("P1W12D").is_err());
        assert!(parse_duration("PT10S12M").is_err());
        // This should yield an error but it's easier to just let it slip through as 0s
        assert_eq!(parse_duration("P").unwrap(), Duration::zero());
    }

    #[rstest]
    #[case("P12W", Duration::weeks(12))]
    #[case("-P12W", -Duration::weeks(12))]
    #[case("P12D", Duration::days(12))]
    #[case("PT12H", Duration::hours(12))]
    #[case("PT12M", Duration::minutes(12))]
    #[case("PT12S", Duration::seconds(12))]
    #[case("-PT12S", -Duration::seconds(12))]
    #[case("P2DT10M12S",
            Duration::days(2) + Duration::minutes(10) + Duration::seconds(12))]
    #[case(
            "PT10M12S",
            Duration::minutes(10) + Duration::seconds(12)
    )]
    // On a roundtrip, P should not be serialised to P
    #[case("PT0S", Duration::zero())]
    fn test_duration_roundtrip(#[case] value: &str, #[case] ref_duration: Duration) {
        let duration = parse_duration(value).unwrap();
        assert_eq!(duration, ref_duration);
        assert_eq!(duration.value(), value);
    }
}