ezcal 0.3.4

Ergonomic iCalendar + vCard library for Rust
Documentation
use crate::common::content_line::{parse_content_line, split_lines, unfold};
use crate::error::{Error, Result};
use crate::vcard::contact::Contact;

/// Parse a vCard string that may contain multiple vCards.
pub(crate) fn parse_vcards(input: &str) -> Result<Vec<Contact>> {
    let unfolded = unfold(input);
    let lines = split_lines(&unfolded);

    let mut contacts = Vec::new();
    let mut idx = 0;

    while idx < lines.len() {
        let prop = parse_content_line(lines[idx], idx + 1)?;
        idx += 1;

        if prop.name == "BEGIN" && prop.value.to_uppercase() == "VCARD" {
            let (contact, new_idx) = parse_single_vcard(&lines, idx)?;
            contacts.push(contact);
            idx = new_idx;
        }
        // Skip any lines between vCards
    }

    Ok(contacts)
}

/// Parse a single vCard (called after BEGIN:VCARD has been consumed).
fn parse_single_vcard(lines: &[&str], start: usize) -> Result<(Contact, usize)> {
    let mut idx = start;
    let mut props = Vec::new();

    while idx < lines.len() {
        let prop = parse_content_line(lines[idx], idx + 1)?;
        idx += 1;

        if prop.name == "END" && prop.value.to_uppercase() == "VCARD" {
            let contact = Contact::from_properties(props)?;
            return Ok((contact, idx));
        }

        props.push(prop);
    }

    Err(Error::UnclosedComponent("VCARD".to_string()))
}

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

    #[test]
    fn parse_minimal_vcard() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Jane Doe\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert_eq!(contacts.len(), 1);
        assert_eq!(contacts[0].get_full_name(), Some("Jane Doe"));
    }

    #[test]
    fn parse_full_vcard() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:12345\r\n\
FN:Jane Doe\r\n\
N:Doe;Jane;;Dr.;\r\n\
EMAIL;TYPE=WORK:jane@example.com\r\n\
EMAIL;TYPE=HOME:jane.doe@personal.com\r\n\
TEL;TYPE=WORK,VOICE:+1-555-0123\r\n\
ORG:Acme Corp\r\n\
TITLE:Software Engineer\r\n\
ADR;TYPE=WORK:;;123 Main St;Anytown;CA;90210;USA\r\n\
NOTE:A note about Jane\r\n\
URL:https://jane.example.com\r\n\
BDAY:19900115\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert_eq!(contacts.len(), 1);
        let c = &contacts[0];
        assert_eq!(c.get_uid(), Some("urn:uuid:12345"));
        assert_eq!(c.get_full_name(), Some("Jane Doe"));
        assert_eq!(c.get_emails().len(), 2);
        assert_eq!(c.get_email(), Some("jane@example.com"));
        assert_eq!(c.get_phone(), Some("+1-555-0123"));
        assert_eq!(c.get_organization(), Some("Acme Corp"));
        assert_eq!(c.get_title(), Some("Software Engineer"));
        assert_eq!(c.get_addresses().len(), 1);
        assert_eq!(c.get_addresses()[0].address.city, "Anytown");
        assert_eq!(c.get_note(), Some("A note about Jane"));
        assert_eq!(c.get_birthday(), Some("19900115"));

        let name = c.get_name().unwrap();
        assert_eq!(name.family, "Doe");
        assert_eq!(name.given, "Jane");
        assert_eq!(name.prefix, "Dr.");
    }

    #[test]
    fn parse_multiple_vcards() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Alice\r\n\
END:VCARD\r\n\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Bob\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert_eq!(contacts.len(), 2);
        assert_eq!(contacts[0].get_full_name(), Some("Alice"));
        assert_eq!(contacts[1].get_full_name(), Some("Bob"));
    }

    #[test]
    fn parse_vcard_with_folded_lines() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Jane Doe\r\n\
NOTE:This is a very long note that has been folded across \r\n multiple lines in the file\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert_eq!(
            contacts[0].get_note(),
            Some("This is a very long note that has been folded across multiple lines in the file")
        );
    }

    #[test]
    fn parse_vcard_escaped_text() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Jane Doe\r\n\
NOTE:Line 1\\nLine 2\\nLine 3\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert_eq!(contacts[0].get_note(), Some("Line 1\nLine 2\nLine 3"));
    }

    #[test]
    fn parse_vcard_unknown_properties_preserved() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Jane\r\n\
X-CUSTOM:custom-value\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        assert!(
            contacts[0]
                .get_extra_properties()
                .iter()
                .any(|p| p.name == "X-CUSTOM" && p.value == "custom-value")
        );
    }

    #[test]
    fn error_unclosed_vcard() {
        let input = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane\r\n";
        let result = parse_vcards(input);
        assert!(result.is_err());
    }

    #[test]
    fn parse_bare_lf() {
        let input = "BEGIN:VCARD\nVERSION:4.0\nFN:Jane Doe\nEND:VCARD\n";
        let contacts = parse_vcards(input).unwrap();
        assert_eq!(contacts.len(), 1);
    }

    #[test]
    fn parse_typed_phone() {
        let input = "\
BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
FN:Test\r\n\
TEL;TYPE=WORK,VOICE:+1-555-0123\r\n\
TEL;TYPE=HOME:+1-555-0456\r\n\
END:VCARD\r\n";

        let contacts = parse_vcards(input).unwrap();
        let phones = contacts[0].get_phones();
        assert_eq!(phones.len(), 2);
        assert_eq!(phones[0].types, vec!["WORK", "VOICE"]);
        assert_eq!(phones[1].types, vec!["HOME"]);
    }
}