ezcal 0.3.4

Ergonomic iCalendar + vCard library for Rust
Documentation
use crate::error::{Error, Result};
use std::fmt;

/// Frequency for a recurrence rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Frequency {
    Secondly,
    Minutely,
    Hourly,
    Daily,
    Weekly,
    Monthly,
    Yearly,
}

impl Frequency {
    pub fn parse(s: &str) -> Result<Self> {
        match s.to_uppercase().as_str() {
            "SECONDLY" => Ok(Frequency::Secondly),
            "MINUTELY" => Ok(Frequency::Minutely),
            "HOURLY" => Ok(Frequency::Hourly),
            "DAILY" => Ok(Frequency::Daily),
            "WEEKLY" => Ok(Frequency::Weekly),
            "MONTHLY" => Ok(Frequency::Monthly),
            "YEARLY" => Ok(Frequency::Yearly),
            other => Err(Error::invalid_value(
                "FREQ",
                format!("unknown frequency: {}", other),
            )),
        }
    }
}

impl fmt::Display for Frequency {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            Frequency::Secondly => "SECONDLY",
            Frequency::Minutely => "MINUTELY",
            Frequency::Hourly => "HOURLY",
            Frequency::Daily => "DAILY",
            Frequency::Weekly => "WEEKLY",
            Frequency::Monthly => "MONTHLY",
            Frequency::Yearly => "YEARLY",
        };
        write!(f, "{}", s)
    }
}

/// Day of the week for recurrence rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Weekday {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday,
}

impl Weekday {
    pub fn parse(s: &str) -> Result<Self> {
        match s.to_uppercase().as_str() {
            "MO" => Ok(Weekday::Monday),
            "TU" => Ok(Weekday::Tuesday),
            "WE" => Ok(Weekday::Wednesday),
            "TH" => Ok(Weekday::Thursday),
            "FR" => Ok(Weekday::Friday),
            "SA" => Ok(Weekday::Saturday),
            "SU" => Ok(Weekday::Sunday),
            other => Err(Error::invalid_value(
                "BYDAY",
                format!("unknown weekday: {}", other),
            )),
        }
    }
}

impl fmt::Display for Weekday {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            Weekday::Monday => "MO",
            Weekday::Tuesday => "TU",
            Weekday::Wednesday => "WE",
            Weekday::Thursday => "TH",
            Weekday::Friday => "FR",
            Weekday::Saturday => "SA",
            Weekday::Sunday => "SU",
        };
        write!(f, "{}", s)
    }
}

/// A weekday with optional ordinal (e.g., `2TU` = second Tuesday, `-1FR` = last Friday).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WeekdayNum {
    pub ordinal: Option<i8>,
    pub weekday: Weekday,
}

impl WeekdayNum {
    pub fn new(weekday: Weekday) -> Self {
        Self {
            ordinal: None,
            weekday,
        }
    }

    pub fn with_ordinal(ordinal: i8, weekday: Weekday) -> Self {
        Self {
            ordinal: Some(ordinal),
            weekday,
        }
    }

    pub fn parse(s: &str) -> Result<Self> {
        let s = s.trim();
        if s.len() < 2 {
            return Err(Error::invalid_value("BYDAY", "too short"));
        }

        // Check if there's a numeric prefix
        let day_start = s
            .find(|c: char| c.is_ascii_alphabetic())
            .ok_or_else(|| Error::invalid_value("BYDAY", "no weekday abbreviation found"))?;

        let ordinal = if day_start > 0 {
            let num: i8 = s[..day_start]
                .parse()
                .map_err(|_| Error::invalid_value("BYDAY", "invalid ordinal"))?;
            Some(num)
        } else {
            None
        };

        let weekday = Weekday::parse(&s[day_start..])?;
        Ok(WeekdayNum { ordinal, weekday })
    }
}

impl fmt::Display for WeekdayNum {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(ord) = self.ordinal {
            write!(f, "{}{}", ord, self.weekday)
        } else {
            write!(f, "{}", self.weekday)
        }
    }
}

/// An iCalendar recurrence rule (RRULE) per RFC 5545 §3.3.10.
///
/// This is a representation-only type in v0.1 -- it can be parsed and serialized
/// but does not expand into concrete occurrence dates.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecurrenceRule {
    pub freq: Frequency,
    pub interval: Option<u32>,
    pub count: Option<u32>,
    pub until: Option<String>,
    pub by_second: Vec<u32>,
    pub by_minute: Vec<u32>,
    pub by_hour: Vec<u32>,
    pub by_day: Vec<WeekdayNum>,
    pub by_month_day: Vec<i8>,
    pub by_year_day: Vec<i16>,
    pub by_week_no: Vec<i8>,
    pub by_month: Vec<u32>,
    pub by_set_pos: Vec<i16>,
    pub wkst: Option<Weekday>,
}

impl RecurrenceRule {
    pub fn new(freq: Frequency) -> Self {
        Self {
            freq,
            interval: None,
            count: None,
            until: None,
            by_second: Vec::new(),
            by_minute: Vec::new(),
            by_hour: Vec::new(),
            by_day: Vec::new(),
            by_month_day: Vec::new(),
            by_year_day: Vec::new(),
            by_week_no: Vec::new(),
            by_month: Vec::new(),
            by_set_pos: Vec::new(),
            wkst: None,
        }
    }

    /// Parse an RRULE value string (the part after `RRULE:`).
    pub fn parse(s: &str) -> Result<Self> {
        let mut freq: Option<Frequency> = None;
        let mut rule = RecurrenceRule::new(Frequency::Daily); // placeholder

        for part in s.split(';') {
            let part = part.trim();
            if part.is_empty() {
                continue;
            }
            let eq = part.find('=').ok_or_else(|| {
                Error::invalid_value("RRULE", format!("missing '=' in part: {}", part))
            })?;
            let key = &part[..eq];
            let val = &part[eq + 1..];

            match key.to_uppercase().as_str() {
                "FREQ" => freq = Some(Frequency::parse(val)?),
                "INTERVAL" => {
                    rule.interval = Some(
                        val.parse()
                            .map_err(|_| Error::invalid_value("INTERVAL", "not a valid number"))?,
                    );
                }
                "COUNT" => {
                    rule.count = Some(
                        val.parse()
                            .map_err(|_| Error::invalid_value("COUNT", "not a valid number"))?,
                    );
                }
                "UNTIL" => {
                    rule.until = Some(val.to_string());
                }
                "BYSECOND" => {
                    rule.by_second = parse_u32_list(val, "BYSECOND")?;
                }
                "BYMINUTE" => {
                    rule.by_minute = parse_u32_list(val, "BYMINUTE")?;
                }
                "BYHOUR" => {
                    rule.by_hour = parse_u32_list(val, "BYHOUR")?;
                }
                "BYDAY" => {
                    rule.by_day = val
                        .split(',')
                        .map(|d| WeekdayNum::parse(d.trim()))
                        .collect::<Result<Vec<_>>>()?;
                }
                "BYMONTHDAY" => {
                    rule.by_month_day = parse_i8_list(val, "BYMONTHDAY")?;
                }
                "BYYEARDAY" => {
                    rule.by_year_day = parse_i16_list(val, "BYYEARDAY")?;
                }
                "BYWEEKNO" => {
                    rule.by_week_no = parse_i8_list(val, "BYWEEKNO")?;
                }
                "BYMONTH" => {
                    rule.by_month = parse_u32_list(val, "BYMONTH")?;
                }
                "BYSETPOS" => {
                    rule.by_set_pos = parse_i16_list(val, "BYSETPOS")?;
                }
                "WKST" => {
                    rule.wkst = Some(Weekday::parse(val)?);
                }
                _ => {} // Ignore unknown parts for forward compatibility
            }
        }

        rule.freq = freq.ok_or_else(|| Error::invalid_value("RRULE", "missing FREQ"))?;
        Ok(rule)
    }
}

impl fmt::Display for RecurrenceRule {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "FREQ={}", self.freq)?;

        if let Some(interval) = self.interval {
            write!(f, ";INTERVAL={}", interval)?;
        }
        if let Some(count) = self.count {
            write!(f, ";COUNT={}", count)?;
        }
        if let Some(ref until) = self.until {
            write!(f, ";UNTIL={}", until)?;
        }
        if !self.by_day.is_empty() {
            write!(f, ";BYDAY=")?;
            for (i, d) in self.by_day.iter().enumerate() {
                if i > 0 {
                    write!(f, ",")?;
                }
                write!(f, "{}", d)?;
            }
        }
        if !self.by_month_day.is_empty() {
            write!(f, ";BYMONTHDAY={}", join_list(&self.by_month_day))?;
        }
        if !self.by_year_day.is_empty() {
            write!(f, ";BYYEARDAY={}", join_list(&self.by_year_day))?;
        }
        if !self.by_week_no.is_empty() {
            write!(f, ";BYWEEKNO={}", join_list(&self.by_week_no))?;
        }
        if !self.by_month.is_empty() {
            write!(f, ";BYMONTH={}", join_list(&self.by_month))?;
        }
        if !self.by_hour.is_empty() {
            write!(f, ";BYHOUR={}", join_list(&self.by_hour))?;
        }
        if !self.by_minute.is_empty() {
            write!(f, ";BYMINUTE={}", join_list(&self.by_minute))?;
        }
        if !self.by_second.is_empty() {
            write!(f, ";BYSECOND={}", join_list(&self.by_second))?;
        }
        if !self.by_set_pos.is_empty() {
            write!(f, ";BYSETPOS={}", join_list(&self.by_set_pos))?;
        }
        if let Some(ref wkst) = self.wkst {
            write!(f, ";WKST={}", wkst)?;
        }
        Ok(())
    }
}

fn join_list<T: fmt::Display>(items: &[T]) -> String {
    items
        .iter()
        .map(|x| x.to_string())
        .collect::<Vec<_>>()
        .join(",")
}

fn parse_u32_list(s: &str, name: &str) -> Result<Vec<u32>> {
    s.split(',')
        .map(|v| {
            v.trim()
                .parse()
                .map_err(|_| Error::invalid_value(name, format!("invalid number: {}", v)))
        })
        .collect()
}

fn parse_i8_list(s: &str, name: &str) -> Result<Vec<i8>> {
    s.split(',')
        .map(|v| {
            v.trim()
                .parse()
                .map_err(|_| Error::invalid_value(name, format!("invalid number: {}", v)))
        })
        .collect()
}

fn parse_i16_list(s: &str, name: &str) -> Result<Vec<i16>> {
    s.split(',')
        .map(|v| {
            v.trim()
                .parse()
                .map_err(|_| Error::invalid_value(name, format!("invalid number: {}", v)))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_simple_weekly() {
        let rule = RecurrenceRule::parse("FREQ=WEEKLY;COUNT=10").unwrap();
        assert_eq!(rule.freq, Frequency::Weekly);
        assert_eq!(rule.count, Some(10));
        assert_eq!(rule.to_string(), "FREQ=WEEKLY;COUNT=10");
    }

    #[test]
    fn parse_complex_monthly() {
        let rule =
            RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=2TU,-1FR;BYMONTH=1,6;INTERVAL=2").unwrap();
        assert_eq!(rule.freq, Frequency::Monthly);
        assert_eq!(rule.interval, Some(2));
        assert_eq!(rule.by_day.len(), 2);
        assert_eq!(rule.by_day[0].ordinal, Some(2));
        assert_eq!(rule.by_day[0].weekday, Weekday::Tuesday);
        assert_eq!(rule.by_day[1].ordinal, Some(-1));
        assert_eq!(rule.by_day[1].weekday, Weekday::Friday);
        assert_eq!(rule.by_month, vec![1, 6]);
    }

    #[test]
    fn roundtrip() {
        let input = "FREQ=YEARLY;INTERVAL=2;BYDAY=MO,TU;BYMONTH=1,7;UNTIL=20301231T235959Z";
        let rule = RecurrenceRule::parse(input).unwrap();
        let output = rule.to_string();
        let rule2 = RecurrenceRule::parse(&output).unwrap();
        assert_eq!(rule, rule2);
    }

    #[test]
    fn missing_freq() {
        let result = RecurrenceRule::parse("INTERVAL=2;COUNT=5");
        assert!(result.is_err());
    }
}