ezcal 0.3.4

Ergonomic iCalendar + vCard library for Rust
Documentation
use crate::common::property::Property;
use crate::datetime::DateTimeValue;
use crate::error::Result;
use crate::ical::alarm::Alarm;
use crate::ical::recurrence::RecurrenceRule;

/// An iCalendar VEVENT component.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
    pub(crate) uid: Option<String>,
    pub(crate) dtstamp: Option<DateTimeValue>,
    pub(crate) dtstart: Option<DateTimeValue>,
    pub(crate) dtend: Option<DateTimeValue>,
    pub(crate) summary: Option<String>,
    pub(crate) description: Option<String>,
    pub(crate) location: Option<String>,
    pub(crate) status: Option<String>,
    pub(crate) categories: Vec<String>,
    pub(crate) priority: Option<u8>,
    pub(crate) url: Option<String>,
    pub(crate) rrule: Option<RecurrenceRule>,
    pub(crate) alarms: Vec<Alarm>,
    pub(crate) extra_properties: Vec<Property>,
}

impl Event {
    /// Create a new event builder.
    pub fn new() -> Self {
        Self {
            uid: None,
            dtstamp: None,
            dtstart: None,
            dtend: None,
            summary: None,
            description: None,
            location: None,
            status: None,
            categories: Vec::new(),
            priority: None,
            url: None,
            rrule: None,
            alarms: Vec::new(),
            extra_properties: Vec::new(),
        }
    }

    pub fn uid(mut self, uid: impl Into<String>) -> Self {
        self.uid = Some(uid.into());
        self
    }

    pub fn summary(mut self, summary: impl Into<String>) -> Self {
        self.summary = Some(summary.into());
        self
    }

    pub fn description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    pub fn location(mut self, location: impl Into<String>) -> Self {
        self.location = Some(location.into());
        self
    }

    /// Set the start date-time. Accepts iCal format (`20260315T090000`) or
    /// ISO 8601 (`2026-03-15T09:00:00`).
    pub fn starts(mut self, dt: &str) -> Self {
        self.dtstart = Some(DateTimeValue::parse(dt).expect("invalid start date-time"));
        self
    }

    /// Set the start date-time from a `DateTimeValue`.
    pub fn starts_dt(mut self, dt: DateTimeValue) -> Self {
        self.dtstart = Some(dt);
        self
    }

    /// Set the end date-time.
    pub fn ends(mut self, dt: &str) -> Self {
        self.dtend = Some(DateTimeValue::parse(dt).expect("invalid end date-time"));
        self
    }

    /// Set the end date-time from a `DateTimeValue`.
    pub fn ends_dt(mut self, dt: DateTimeValue) -> Self {
        self.dtend = Some(dt);
        self
    }

    pub fn status(mut self, status: impl Into<String>) -> Self {
        self.status = Some(status.into());
        self
    }

    pub fn categories(mut self, categories: Vec<String>) -> Self {
        self.categories = categories;
        self
    }

    pub fn add_category(mut self, category: impl Into<String>) -> Self {
        self.categories.push(category.into());
        self
    }

    pub fn priority(mut self, priority: u8) -> Self {
        self.priority = Some(priority);
        self
    }

    pub fn url(mut self, url: impl Into<String>) -> Self {
        self.url = Some(url.into());
        self
    }

    pub fn rrule(mut self, rrule: RecurrenceRule) -> Self {
        self.rrule = Some(rrule);
        self
    }

    pub fn alarm(mut self, alarm: Alarm) -> Self {
        self.alarms.push(alarm);
        self
    }

    /// Add an extra property that isn't covered by the typed fields.
    pub fn property(mut self, prop: Property) -> Self {
        self.extra_properties.push(prop);
        self
    }

    // --- Accessors ---

    pub fn get_uid(&self) -> Option<&str> {
        self.uid.as_deref()
    }

    pub fn get_summary(&self) -> Option<&str> {
        self.summary.as_deref()
    }

    pub fn get_description(&self) -> Option<&str> {
        self.description.as_deref()
    }

    pub fn get_location(&self) -> Option<&str> {
        self.location.as_deref()
    }

    pub fn get_starts(&self) -> Option<&DateTimeValue> {
        self.dtstart.as_ref()
    }

    pub fn get_ends(&self) -> Option<&DateTimeValue> {
        self.dtend.as_ref()
    }

    pub fn get_status(&self) -> Option<&str> {
        self.status.as_deref()
    }

    pub fn get_categories(&self) -> &[String] {
        &self.categories
    }

    pub fn get_priority(&self) -> Option<u8> {
        self.priority
    }

    pub fn get_url(&self) -> Option<&str> {
        self.url.as_deref()
    }

    pub fn get_rrule(&self) -> Option<&RecurrenceRule> {
        self.rrule.as_ref()
    }

    pub fn get_alarms(&self) -> &[Alarm] {
        &self.alarms
    }

    pub fn get_extra_properties(&self) -> &[Property] {
        &self.extra_properties
    }

    /// Convert this event to a list of properties (for serialization).
    pub(crate) fn to_properties(&self) -> Vec<Property> {
        let mut props = Vec::new();

        let uid = self
            .uid
            .clone()
            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
        props.push(Property::new("UID", uid));

        if let Some(ref dtstamp) = self.dtstamp {
            props.push(dtstamp.to_property("DTSTAMP"));
        } else {
            // Generate current UTC timestamp
            #[cfg(feature = "chrono")]
            {
                let now = chrono::Utc::now();
                let dt = DateTimeValue::from_chrono_utc(now);
                props.push(dt.to_property("DTSTAMP"));
            }
            #[cfg(not(feature = "chrono"))]
            {
                props.push(Property::new("DTSTAMP", "19700101T000000Z"));
            }
        }

        if let Some(ref dt) = self.dtstart {
            props.push(dt.to_property("DTSTART"));
        }
        if let Some(ref dt) = self.dtend {
            props.push(dt.to_property("DTEND"));
        }
        if let Some(ref s) = self.summary {
            props.push(Property::new("SUMMARY", escape_text(s)));
        }
        if let Some(ref s) = self.description {
            props.push(Property::new("DESCRIPTION", escape_text(s)));
        }
        if let Some(ref s) = self.location {
            props.push(Property::new("LOCATION", escape_text(s)));
        }
        if let Some(ref s) = self.status {
            props.push(Property::new("STATUS", s));
        }
        if !self.categories.is_empty() {
            props.push(Property::new(
                "CATEGORIES",
                self.categories
                    .iter()
                    .map(|c| escape_text(c))
                    .collect::<Vec<_>>()
                    .join(","),
            ));
        }
        if let Some(p) = self.priority {
            props.push(Property::new("PRIORITY", p.to_string()));
        }
        if let Some(ref u) = self.url {
            props.push(Property::new("URL", u));
        }
        if let Some(ref rrule) = self.rrule {
            props.push(Property::new("RRULE", rrule.to_string()));
        }

        props.extend(self.extra_properties.clone());

        props
    }

    /// Build this event from parsed properties.
    pub(crate) fn from_properties(props: Vec<Property>, alarms: Vec<Alarm>) -> Result<Self> {
        let mut event = Event::new();
        event.alarms = alarms;
        let mut extra = Vec::new();

        for prop in props {
            match prop.name.as_str() {
                "UID" => event.uid = Some(prop.value.clone()),
                "DTSTAMP" => event.dtstamp = Some(DateTimeValue::from_property(&prop)?),
                "DTSTART" => event.dtstart = Some(DateTimeValue::from_property(&prop)?),
                "DTEND" => event.dtend = Some(DateTimeValue::from_property(&prop)?),
                "SUMMARY" => event.summary = Some(unescape_text(&prop.value)),
                "DESCRIPTION" => event.description = Some(unescape_text(&prop.value)),
                "LOCATION" => event.location = Some(unescape_text(&prop.value)),
                "STATUS" => event.status = Some(prop.value.clone()),
                "CATEGORIES" => {
                    event.categories = prop
                        .value
                        .split(',')
                        .map(|s| unescape_text(s.trim()))
                        .collect();
                }
                "PRIORITY" => {
                    event.priority = prop.value.parse().ok();
                }
                "URL" => event.url = Some(prop.value.clone()),
                "RRULE" => event.rrule = Some(RecurrenceRule::parse(&prop.value)?),
                _ => extra.push(prop),
            }
        }

        event.extra_properties = extra;
        Ok(event)
    }
}

impl Default for Event {
    fn default() -> Self {
        Self::new()
    }
}

/// Escape text per RFC 5545 §3.3.11.
pub(crate) fn escape_text(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace(';', "\\;")
        .replace(',', "\\,")
        .replace('\n', "\\n")
}

/// Unescape text per RFC 5545 §3.3.11.
pub(crate) fn unescape_text(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\\' {
            match chars.peek() {
                Some('n') | Some('N') => {
                    result.push('\n');
                    chars.next();
                }
                Some('\\') => {
                    result.push('\\');
                    chars.next();
                }
                Some(';') => {
                    result.push(';');
                    chars.next();
                }
                Some(',') => {
                    result.push(',');
                    chars.next();
                }
                _ => result.push('\\'),
            }
        } else {
            result.push(ch);
        }
    }
    result
}

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

    #[test]
    fn escape_roundtrip() {
        let original = "Hello\\World; with, commas\nand newlines";
        let escaped = escape_text(original);
        let unescaped = unescape_text(&escaped);
        assert_eq!(unescaped, original);
    }

    #[test]
    fn event_builder() {
        let event = Event::new()
            .summary("Team Standup")
            .location("Room 42")
            .starts("2026-03-15T09:00:00")
            .ends("2026-03-15T09:30:00");

        assert_eq!(event.get_summary(), Some("Team Standup"));
        assert_eq!(event.get_location(), Some("Room 42"));
        assert!(event.get_starts().is_some());
        assert!(event.get_ends().is_some());
    }
}